some experiments

This commit is contained in:
thomashamburg 2025-11-08 16:17:55 +01:00
parent 07b54b15ae
commit 4120caf1b4
71 changed files with 8998 additions and 5 deletions

5
crud/.server/Makefile Normal file
View File

@ -0,0 +1,5 @@
run:
go run .
build:
go build -tags prod .

98
crud/.server/api/echo.go Normal file
View File

@ -0,0 +1,98 @@
package api
import (
"encoding/json"
"log"
"mime"
"net/http"
"net/url"
"strings"
)
// isJSONLike checks if a string looks like it might be JSON
func isJSONLike(s string) bool {
s = strings.TrimSpace(s)
return (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) ||
(strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]"))
}
// RequestEcho represents the structure of our response
type RequestEcho struct {
URL string `json:"url"`
Method string `json:"method"`
Path string `json:"path"`
QueryParams url.Values `json:"query_params"`
Headers http.Header `json:"headers"` // Use http.Header for canonical keys
Body interface{} `json:"body"` // Can be structured JSON or raw string
BodyRaw string `json:"body_raw"` // Original body as string
RemoteAddr string `json:"remote_addr"`
ContentType string `json:"content_type"`
ContentLength int64 `json:"content_length"`
}
func EchoHandler() http.Handler
{
// Read body
bodyBytes := c.Body() // Fiber's way to get the body
bodyString := string(bodyBytes)
// Try to parse body as JSON
var parsedBody interface{}
contentTypeHeader := c.Get("Content-Type") // Get Content-Type header
if len(bodyBytes) > 0 {
mediaType, _, err := mime.ParseMediaType(contentTypeHeader)
// Only attempt JSON parsing if Content-Type is application/json or it looks like JSON
if (err == nil && mediaType == "application/json") || isJSONLike(bodyString) {
if jsonErr := json.Unmarshal(bodyBytes, &parsedBody); jsonErr != nil {
// If JSON parsing fails, parsedBody remains nil.
// It will be set to bodyString later if it's still nil.
parsedBody = nil
}
}
}
if parsedBody == nil && len(bodyBytes) > 0 {
// For non-JSON bodies or if JSON parsing failed, use the raw string
parsedBody = bodyString
}
// Query Parameters
queryParams, _ := url.ParseQuery(string(c.Request().URI().QueryString()))
// Headers - ensuring canonical keys like net/http.Header
headers := make(http.Header)
c.Context().Request.Header.VisitAll(func(key, value []byte) {
k := string(key)
v := string(value)
headers.Add(k, v) // http.Header.Add appends, and canonicalizes the key on first Set/Add
})
// Create our response structure
echo := RequestEcho{
URL: c.OriginalURL(), // Path and query string
Method: c.Method(),
Path: c.Path(),
QueryParams: queryParams,
Headers: headers,
Body: parsedBody,
BodyRaw: bodyString,
RemoteAddr: c.Context().RemoteAddr().String(), // IP and Port
ContentType: contentTypeHeader,
ContentLength: int64(c.Context().Request.Header.ContentLength()),
}
// Marshal to JSON and write response
jsonResponse, err := json.MarshalIndent(echo, "", " ")
if err != nil {
log.Printf("Error creating JSON response: %v", err)
return c.Status(http.StatusInternalServerError).SendString("Error creating JSON response")
}
c.Set("Content-Type", "application/json")
c.Status(http.StatusOK)
// Log the request to stdout
// fmt.Printf("Received %s request to %s\n", c.Method(), c.Path())
return c.Send(jsonResponse)
}

BIN
crud/.server/crud Executable file

Binary file not shown.

View File

@ -0,0 +1,115 @@
// This is auto-generated by Datastar. DO NOT EDIT.
package datastar
import "time"
const (
DatastarKey = "datastar"
Version = "1.0.0-beta.11"
VersionClientByteSize = 40026
VersionClientByteSizeGzip = 14900
//region Default durations
// The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.
DefaultSseRetryDuration = 1000 * time.Millisecond
//endregion Default durations
//region Default strings
// The default attributes for <script/> element use when executing scripts. It is a set of key-value pairs delimited by a newline \\n character.
DefaultExecuteScriptAttributes = "type module"
//endregion Default strings
//region Dataline literals
SelectorDatalineLiteral = "selector "
MergeModeDatalineLiteral = "mergeMode "
FragmentsDatalineLiteral = "fragments "
UseViewTransitionDatalineLiteral = "useViewTransition "
SignalsDatalineLiteral = "signals "
OnlyIfMissingDatalineLiteral = "onlyIfMissing "
PathsDatalineLiteral = "paths "
ScriptDatalineLiteral = "script "
AttributesDatalineLiteral = "attributes "
AutoRemoveDatalineLiteral = "autoRemove "
//endregion Dataline literals
)
var (
//region Default booleans
// Should fragments be merged using the ViewTransition API?
DefaultFragmentsUseViewTransitions = false
// Should a given set of signals merge if they are missing?
DefaultMergeSignalsOnlyIfMissing = false
// Should script element remove itself after execution?
DefaultExecuteScriptAutoRemove = true
//endregion Default booleans
)
//region Enums
//region The mode in which a fragment is merged into the DOM.
type FragmentMergeMode string
const (
// Default value for FragmentMergeMode
// Morphs the fragment into the existing element using idiomorph.
DefaultFragmentMergeMode = FragmentMergeModeMorph
// Morphs the fragment into the existing element using idiomorph.
FragmentMergeModeMorph FragmentMergeMode = "morph"
// Replaces the inner HTML of the existing element.
FragmentMergeModeInner FragmentMergeMode = "inner"
// Replaces the outer HTML of the existing element.
FragmentMergeModeOuter FragmentMergeMode = "outer"
// Prepends the fragment to the existing element.
FragmentMergeModePrepend FragmentMergeMode = "prepend"
// Appends the fragment to the existing element.
FragmentMergeModeAppend FragmentMergeMode = "append"
// Inserts the fragment before the existing element.
FragmentMergeModeBefore FragmentMergeMode = "before"
// Inserts the fragment after the existing element.
FragmentMergeModeAfter FragmentMergeMode = "after"
// Upserts the attributes of the existing element.
FragmentMergeModeUpsertAttributes FragmentMergeMode = "upsertAttributes"
)
//endregion FragmentMergeMode
//region The type protocol on top of SSE which allows for core pushed based communication between the server and the client.
type EventType string
const (
// An event for merging HTML fragments into the DOM.
EventTypeMergeFragments EventType = "datastar-merge-fragments"
// An event for merging signals.
EventTypeMergeSignals EventType = "datastar-merge-signals"
// An event for removing HTML fragments from the DOM.
EventTypeRemoveFragments EventType = "datastar-remove-fragments"
// An event for removing signals.
EventTypeRemoveSignals EventType = "datastar-remove-signals"
// An event for executing <script/> elements in the browser.
EventTypeExecuteScript EventType = "datastar-execute-script"
)
//endregion EventType
//endregion Enums

View File

@ -0,0 +1,234 @@
package datastar
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// ConsoleLog is a convenience method for [see.ExecuteScript].
// It is equivalent to calling [see.ExecuteScript] with [see.WithScript] option set to `console.log(msg)`.
func (sse *ServerSentEventGenerator) ConsoleLog(msg string, opts ...ExecuteScriptOption) error {
call := fmt.Sprintf("console.log(%q)", msg)
return sse.ExecuteScript(call, opts...)
}
// ConsoleLogf is a convenience method for [see.ExecuteScript].
// It is equivalent to calling [see.ExecuteScript] with [see.WithScript] option set to `console.log(fmt.Sprintf(format, args...))`.
func (sse *ServerSentEventGenerator) ConsoleLogf(format string, args ...any) error {
return sse.ConsoleLog(fmt.Sprintf(format, args...))
}
// ConsoleError is a convenience method for [see.ExecuteScript].
// It is equivalent to calling [see.ExecuteScript] with [see.WithScript] option set to `console.error(msg)`.
func (sse *ServerSentEventGenerator) ConsoleError(err error, opts ...ExecuteScriptOption) error {
call := fmt.Sprintf("console.error(%q)", err.Error())
return sse.ExecuteScript(call, opts...)
}
// Redirectf is a convenience method for [see.ExecuteScript].
// It sends a redirect event to the client formatted using [fmt.Sprintf].
func (sse *ServerSentEventGenerator) Redirectf(format string, args ...any) error {
url := fmt.Sprintf(format, args...)
return sse.Redirect(url)
}
// Redirect is a convenience method for [see.ExecuteScript].
// It sends a redirect event to the client .
func (sse *ServerSentEventGenerator) Redirect(url string, opts ...ExecuteScriptOption) error {
js := fmt.Sprintf("setTimeout(() => window.location.href = %q)", url)
return sse.ExecuteScript(js, opts...)
}
// dispatchCustomEventOptions holds the configuration data
// modified by [DispatchCustomEventOption]s
// for dispatching custom events to the client.
type dispatchCustomEventOptions struct {
EventID string
RetryDuration time.Duration
Selector string
Bubbles bool
Cancelable bool
Composed bool
}
// DispatchCustomEventOption configures one custom
// server-sent event.
type DispatchCustomEventOption func(*dispatchCustomEventOptions)
// WithDispatchCustomEventEventID configures an optional event ID for the custom event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithDispatchCustomEventEventID(id string) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.EventID = id
}
}
// WithDispatchCustomEventRetryDuration overrides the [DefaultSseRetryDuration] for one custom event.
func WithDispatchCustomEventRetryDuration(retryDuration time.Duration) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.RetryDuration = retryDuration
}
}
// WithDispatchCustomEventSelector replaces the default custom event target `document` with a
// [CSS selector]. If the selector matches multiple HTML elements, the event will be dispatched
// from each one. For example, if the selector is `#my-element`, the event will be dispatched
// from the element with the ID `my-element`. If the selector is `main > section`, the event will be dispatched
// from each `<section>` element which is a direct child of the `<main>` element.
//
// [CSS selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors
func WithDispatchCustomEventSelector(selector string) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Selector = selector
}
}
// WithDispatchCustomEventBubbles overrides the default custom [event bubbling] `true` value.
// Setting bubbling to `false` is equivalent to calling `event.stopPropagation()` Javascript
// command on the client side for the dispatched event. This prevents the event from triggering
// event handlers of its parent elements.
//
// [event bubbling]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Event_bubbling
func WithDispatchCustomEventBubbles(bubbles bool) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Bubbles = bubbles
}
}
// WithDispatchCustomEventCancelable overrides the default custom [event cancelability] `true` value.
// Setting cancelability to `false` is blocks `event.preventDefault()` Javascript
// command on the client side for the dispatched event.
//
// [event cancelability]: https://developer.mozilla.org/en-US/docs/Web/API/Event/cancelable
func WithDispatchCustomEventCancelable(cancelable bool) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Cancelable = cancelable
}
}
// WithDispatchCustomEventComposed overrides the default custom [event composed] `true` value.
// It indicates whether or not the event will propagate across the shadow HTML DOM boundary into
// the document DOM tree. When `false`, the shadow root will be the last node to be offered the event.
//
// [event composed]: https://developer.mozilla.org/en-US/docs/Web/API/Event/composed
func WithDispatchCustomEventComposed(composed bool) DispatchCustomEventOption {
return func(o *dispatchCustomEventOptions) {
o.Composed = composed
}
}
// DispatchCustomEvent is a convenience method for dispatching a custom event by executing
// a client side script via [sse.ExecuteScript] call. The detail struct is marshaled to JSON and
// passed as a parameter to the event.
func (sse *ServerSentEventGenerator) DispatchCustomEvent(eventName string, detail any, opts ...DispatchCustomEventOption) error {
if eventName == "" {
return fmt.Errorf("eventName is required")
}
detailsJSON, err := json.Marshal(detail)
if err != nil {
return fmt.Errorf("failed to marshal detail: %w", err)
}
const defaultSelector = "document"
options := dispatchCustomEventOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
Selector: defaultSelector,
Bubbles: true,
Cancelable: true,
Composed: true,
}
for _, opt := range opts {
opt(&options)
}
elementsJS := `[document]`
if options.Selector != "" && options.Selector != defaultSelector {
elementsJS = fmt.Sprintf(`document.querySelectorAll(%q)`, options.Selector)
}
js := fmt.Sprintf(`
const elements = %s
const event = new CustomEvent(%q, {
bubbles: %t,
cancelable: %t,
composed: %t,
detail: %s,
});
elements.forEach((element) => {
element.dispatchEvent(event);
});
`,
elementsJS,
eventName,
options.Bubbles,
options.Cancelable,
options.Composed,
string(detailsJSON),
)
executeOptions := make([]ExecuteScriptOption, 0)
if options.EventID != "" {
executeOptions = append(executeOptions, WithExecuteScriptEventID(options.EventID))
}
if options.RetryDuration != 0 {
executeOptions = append(executeOptions, WithExecuteScriptRetryDuration(options.RetryDuration))
}
return sse.ExecuteScript(js, executeOptions...)
}
// ReplaceURL replaces the current URL in the browser's history.
func (sse *ServerSentEventGenerator) ReplaceURL(u url.URL, opts ...ExecuteScriptOption) error {
js := fmt.Sprintf(`window.history.replaceState({}, "", %q)`, u.String())
return sse.ExecuteScript(js, opts...)
}
// ReplaceURLQuerystring is a convenience wrapper for [sse.ReplaceURL] that replaces the query
// string of the current URL request with new a new query built from the provided values.
func (sse *ServerSentEventGenerator) ReplaceURLQuerystring(r *http.Request, values url.Values, opts ...ExecuteScriptOption) error {
// TODO: rename this function to ReplaceURLQuery
u := *r.URL
u.RawQuery = values.Encode()
return sse.ReplaceURL(u, opts...)
}
// Prefetch is a convenience wrapper for [sse.ExecuteScript] that prefetches the provided links.
// It follows the Javascript [speculation rules API] prefetch specification.
//
// [speculation rules API]: https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
func (sse *ServerSentEventGenerator) Prefetch(urls ...string) error {
wrappedURLs := make([]string, len(urls))
for i, url := range urls {
wrappedURLs[i] = fmt.Sprintf(`"%s"`, url)
}
script := fmt.Sprintf(`
{
"prefetch": [
{
"source": "list",
"urls": [
%s
]
}
]
}
`, strings.Join(wrappedURLs, ",\n\t\t\t\t"))
return sse.ExecuteScript(
script,
WithExecuteScriptAutoRemove(false),
WithExecuteScriptAttributes("type speculationrules"),
)
}

View File

@ -0,0 +1,112 @@
package datastar
import (
"fmt"
"strconv"
"strings"
"time"
)
// executeScriptOptions hold script options that will be translated to [SSEEventOptions].
type executeScriptOptions struct {
EventID string
RetryDuration time.Duration
Attributes []string
AutoRemove *bool
}
// ExecuteScriptOption configures script execution event that will be sent to the client.
type ExecuteScriptOption func(*executeScriptOptions)
// WithExecuteScriptEventID configures an optional event ID for the script execution event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithExecuteScriptEventID(id string) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.EventID = id
}
}
// WithExecuteScriptRetryDuration overrides the [DefaultSseRetryDuration] for this script
// execution only.
func WithExecuteScriptRetryDuration(retryDuration time.Duration) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.RetryDuration = retryDuration
}
}
// WithExecuteScriptAttributes overrides the default script attribute
// value `type module`, which renders as `<script type="module">` in client's browser.
// Each attribute should include a pair of plain words representing the attribute name and value
// without any formatting.
func WithExecuteScriptAttributes(attributes ...string) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.Attributes = attributes
}
}
// WithExecuteScriptAttributeKVs is an alternative option for [WithExecuteScriptAttributes].
// Even parameters are keys, odd parameters are their values.
func WithExecuteScriptAttributeKVs(kvs ...string) ExecuteScriptOption {
if len(kvs)%2 != 0 {
panic("WithExecuteScriptAttributeKVs requires an even number of arguments")
}
attributes := make([]string, 0, len(kvs)/2)
for i := 0; i < len(kvs); i += 2 {
attribute := fmt.Sprintf("%s %s", kvs[i], kvs[i+1])
attributes = append(attributes, attribute)
}
return WithExecuteScriptAttributes(attributes...)
}
// WithExecuteScriptAutoRemove requires the client to eliminate the script element after its execution.
func WithExecuteScriptAutoRemove(autoremove bool) ExecuteScriptOption {
return func(o *executeScriptOptions) {
o.AutoRemove = &autoremove
}
}
// ExecuteScript runs a script in the client browser. Seperate commands with semicolons.
func (sse *ServerSentEventGenerator) ExecuteScript(scriptContents string, opts ...ExecuteScriptOption) error {
options := &executeScriptOptions{
RetryDuration: DefaultSseRetryDuration,
Attributes: []string{"type module"},
}
for _, opt := range opts {
opt(options)
}
sendOpts := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOpts = append(sendOpts, WithSSEEventId(options.EventID))
}
if options.RetryDuration != DefaultSseRetryDuration {
sendOpts = append(sendOpts, WithSSERetryDuration(options.RetryDuration))
}
dataLines := make([]string, 0, 64)
if options.AutoRemove != nil && *options.AutoRemove != DefaultExecuteScriptAutoRemove {
dataLines = append(dataLines, AutoRemoveDatalineLiteral+strconv.FormatBool(*options.AutoRemove))
}
dataLinesJoined := strings.Join(dataLines, NewLine)
if dataLinesJoined != DefaultExecuteScriptAttributes {
for _, attribute := range options.Attributes {
dataLines = append(dataLines, AttributesDatalineLiteral+attribute)
}
}
scriptLines := strings.Split(scriptContents, NewLine)
for _, line := range scriptLines {
dataLines = append(dataLines, ScriptDatalineLiteral+line)
}
return sse.Send(
EventTypeExecuteScript,
dataLines,
sendOpts...,
)
}

View File

@ -0,0 +1,190 @@
package datastar
import (
"context"
"fmt"
"io"
"github.com/valyala/bytebufferpool"
)
// ValidFragmentMergeTypes is a list of valid fragment merge modes.
var ValidFragmentMergeTypes = []FragmentMergeMode{
FragmentMergeModeMorph,
FragmentMergeModeInner,
FragmentMergeModeOuter,
FragmentMergeModePrepend,
FragmentMergeModeAppend,
FragmentMergeModeBefore,
FragmentMergeModeAfter,
FragmentMergeModeUpsertAttributes,
}
// FragmentMergeTypeFromString converts a string to a [FragmentMergeMode].
func FragmentMergeTypeFromString(s string) (FragmentMergeMode, error) {
switch s {
case "morph":
return FragmentMergeModeMorph, nil
case "inner":
return FragmentMergeModeInner, nil
case "outer":
return FragmentMergeModeOuter, nil
case "prepend":
return FragmentMergeModePrepend, nil
case "append":
return FragmentMergeModeAppend, nil
case "before":
return FragmentMergeModeBefore, nil
case "after":
return FragmentMergeModeAfter, nil
case "upsertAttributes":
return FragmentMergeModeUpsertAttributes, nil
default:
return "", fmt.Errorf("invalid fragment merge type: %s", s)
}
}
// WithMergeMorph creates a MergeFragmentOption that merges fragments using the morph mode.
func WithMergeMorph() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeMorph)
}
// WithMergeInner creates a MergeFragmentOption that merges fragments using the inner mode.
func WithMergeInner() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeInner)
}
// WithMergeOuter creates a MergeFragmentOption that merges fragments using the outer mode.
func WithMergeOuter() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeOuter)
}
// WithMergePrepend creates a MergeFragmentOption that merges fragments using the prepend mode.
func WithMergePrepend() MergeFragmentOption {
return WithMergeMode(FragmentMergeModePrepend)
}
// WithMergeAppend creates a MergeFragmentOption that merges fragments using the append mode.
func WithMergeAppend() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeAppend)
}
// WithMergeBefore creates a MergeFragmentOption that merges fragments using the before mode.
func WithMergeBefore() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeBefore)
}
// WithMergeAfter creates a MergeFragmentOption that merges fragments using the after mode.
func WithMergeAfter() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeAfter)
}
// WithMergeUpsertAttributes creates a MergeFragmentOption that merges fragments using the upsert attributes mode.
func WithMergeUpsertAttributes() MergeFragmentOption {
return WithMergeMode(FragmentMergeModeUpsertAttributes)
}
// WithSelectorID is a convenience wrapper for [WithSelector] option
// equivalent to calling `WithSelector("#"+id)`.
func WithSelectorID(id string) MergeFragmentOption {
return WithSelector("#" + id)
}
// WithViewTransitions enables the use of view transitions when merging fragments.
func WithViewTransitions() MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.UseViewTransitions = true
}
}
// WithoutViewTransitions disables the use of view transitions when merging fragments.
func WithoutViewTransitions() MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.UseViewTransitions = false
}
}
// MergeFragmentf is a convenience wrapper for [MergeFragments] option
// equivalent to calling `MergeFragments(fmt.Sprintf(format, args...))`.
func (sse *ServerSentEventGenerator) MergeFragmentf(format string, args ...any) error {
return sse.MergeFragments(fmt.Sprintf(format, args...))
}
// TemplComponent satisfies the component rendering interface for HTML template engine [Templ].
// This separate type ensures compatibility with [Templ] without imposing a dependency requirement
// on those who prefer to use a different template engine.
//
// [Templ]: https://templ.guide/
type TemplComponent interface {
Render(ctx context.Context, w io.Writer) error
}
// MergeFragmentTempl is a convenience adaptor of [sse.MergeFragments] for [TemplComponent].
func (sse *ServerSentEventGenerator) MergeFragmentTempl(c TemplComponent, opts ...MergeFragmentOption) error {
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
if err := c.Render(sse.Context(), buf); err != nil {
return fmt.Errorf("failed to merge fragment: %w", err)
}
if err := sse.MergeFragments(buf.String(), opts...); err != nil {
return fmt.Errorf("failed to merge fragment: %w", err)
}
return nil
}
// GoStarElementRenderer satisfies the component rendering interface for HTML template engine [GoStar].
// This separate type ensures compatibility with [GoStar] without imposing a dependency requirement
// on those who prefer to use a different template engine.
//
// [GoStar]: https://github.com/delaneyj/gostar
type GoStarElementRenderer interface {
Render(w io.Writer) error
}
// MergeFragmentGostar is a convenience adaptor of [sse.MergeFragments] for [GoStarElementRenderer].
func (sse *ServerSentEventGenerator) MergeFragmentGostar(child GoStarElementRenderer, opts ...MergeFragmentOption) error {
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
if err := child.Render(buf); err != nil {
return fmt.Errorf("failed to render: %w", err)
}
if err := sse.MergeFragments(buf.String(), opts...); err != nil {
return fmt.Errorf("failed to merge fragment: %w", err)
}
return nil
}
// GetSSE is a convenience method for generating Datastar backend [get] action attribute.
//
// [get]: https://data-star.dev/reference/action_plugins#get
func GetSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@get('%s')`, fmt.Sprintf(urlFormat, args...))
}
// PostSSE is a convenience method for generating Datastar backend [post] action attribute.
//
// [post]: https://data-star.dev/reference/action_plugins#post
func PostSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@post('%s')`, fmt.Sprintf(urlFormat, args...))
}
// PutSSE is a convenience method for generating Datastar backend [put] action attribute.
//
// [put]: https://data-star.dev/reference/action_plugins#put
func PutSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@put('%s')`, fmt.Sprintf(urlFormat, args...))
}
// PatchSSE is a convenience method for generating Datastar backend [patch] action attribute.
//
// [patch]: https://data-star.dev/reference/action_plugins#patch
func PatchSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@patch('%s')`, fmt.Sprintf(urlFormat, args...))
}
// DeleteSSE is a convenience method for generating Datastar backend [delete] action attribute.
//
// [delete]: https://data-star.dev/reference/action_plugins#delete
func DeleteSSE(urlFormat string, args ...any) string {
return fmt.Sprintf(`@delete('%s')`, fmt.Sprintf(urlFormat, args...))
}

View File

@ -0,0 +1,190 @@
package datastar
import (
"fmt"
"strconv"
"strings"
"time"
)
// mergeFragmentOptions holds the configuration data for [MergeFragmentOption]s used
// for initialization of [sse.MergeFragments] event.
type mergeFragmentOptions struct {
EventID string
RetryDuration time.Duration
Selector string
MergeMode FragmentMergeMode
UseViewTransitions bool
}
// MergeFragmentOption configures the [sse.MergeFragments] event initialization.
type MergeFragmentOption func(*mergeFragmentOptions)
// WithMergeFragmentsEventID configures an optional event ID for the fragments merge event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithMergeFragmentsEventID(id string) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.EventID = id
}
}
// WithSelectorf is a convenience wrapper for [WithSelector] option that formats the selector string
// using the provided format and arguments similar to [fmt.Sprintf].
func WithSelectorf(selectorFormat string, args ...any) MergeFragmentOption {
selector := fmt.Sprintf(selectorFormat, args...)
return WithSelector(selector)
}
// WithSelector specifies the [CSS selector] for HTML elements that a fragment will be merged over or
// merged next to, depending on the merge mode.
//
// [CSS selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors
func WithSelector(selector string) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.Selector = selector
}
}
// WithMergeMode overrides the [DefaultFragmentMergeMode] for the fragment.
// Choose a valid [FragmentMergeMode].
func WithMergeMode(merge FragmentMergeMode) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.MergeMode = merge
}
}
// WithUseViewTransitions specifies whether to use [view transitions] when merging fragments.
//
// [view transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API
func WithUseViewTransitions(useViewTransition bool) MergeFragmentOption {
return func(o *mergeFragmentOptions) {
o.UseViewTransitions = useViewTransition
}
}
// MergeFragments sends an HTML fragment to the client to update the DOM tree with.
func (sse *ServerSentEventGenerator) MergeFragments(fragment string, opts ...MergeFragmentOption) error {
options := &mergeFragmentOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
Selector: "",
MergeMode: FragmentMergeModeMorph,
}
for _, opt := range opts {
opt(options)
}
sendOptions := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOptions = append(sendOptions, WithSSEEventId(options.EventID))
}
if options.RetryDuration > 0 {
sendOptions = append(sendOptions, WithSSERetryDuration(options.RetryDuration))
}
dataRows := make([]string, 0, 4)
if options.Selector != "" {
dataRows = append(dataRows, SelectorDatalineLiteral+options.Selector)
}
if options.MergeMode != FragmentMergeModeMorph {
dataRows = append(dataRows, MergeModeDatalineLiteral+string(options.MergeMode))
}
if options.UseViewTransitions {
dataRows = append(dataRows, UseViewTransitionDatalineLiteral+"true")
}
if fragment != "" {
parts := strings.Split(fragment, "\n")
for _, part := range parts {
dataRows = append(dataRows, FragmentsDatalineLiteral+part)
}
}
if err := sse.Send(
EventTypeMergeFragments,
dataRows,
sendOptions...,
); err != nil {
return fmt.Errorf("failed to send fragment: %w", err)
}
return nil
}
// mergeFragmentOptions holds the configuration data for [RemoveFragmentsOption]s used
// for initialization of [sse.RemoveFragments] event.
type removeFragmentsOptions struct {
EventID string
RetryDuration time.Duration
UseViewTransitions *bool
}
// RemoveFragmentsOption configures the [sse.RemoveFragments] event.
type RemoveFragmentsOption func(*removeFragmentsOptions)
// WithRemoveEventID configures an optional event ID for the fragment removal event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithRemoveEventID(id string) RemoveFragmentsOption {
return func(o *removeFragmentsOptions) {
o.EventID = id
}
}
// WithExecuteScriptRetryDuration overrides the [DefaultSseRetryDuration] for this script
// execution only.
func WithRemoveRetryDuration(d time.Duration) RemoveFragmentsOption {
return func(o *removeFragmentsOptions) {
o.RetryDuration = d
}
}
// WithRemoveUseViewTransitions specifies whether to use [view transitions] when merging fragments.
//
// [view transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API
func WithRemoveUseViewTransitions(useViewTransition bool) RemoveFragmentsOption {
return func(o *removeFragmentsOptions) {
o.UseViewTransitions = &useViewTransition
}
}
// MergeFragments sends a [CSS selector] to the client to update the DOM tree by removing matching elements.
//
// [CSS selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors
func (sse *ServerSentEventGenerator) RemoveFragments(selector string, opts ...RemoveFragmentsOption) error {
if selector == "" {
panic("missing " + SelectorDatalineLiteral)
}
options := &removeFragmentsOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
UseViewTransitions: nil,
}
for _, opt := range opts {
opt(options)
}
dataRows := []string{SelectorDatalineLiteral + selector}
if options.UseViewTransitions != nil {
dataRows = append(dataRows, UseViewTransitionDatalineLiteral+strconv.FormatBool(*options.UseViewTransitions))
}
sendOptions := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOptions = append(sendOptions, WithSSEEventId(options.EventID))
}
if options.RetryDuration > 0 {
sendOptions = append(sendOptions, WithSSERetryDuration(options.RetryDuration))
}
if err := sse.Send(EventTypeRemoveFragments, dataRows, sendOptions...); err != nil {
return fmt.Errorf("failed to send remove: %w", err)
}
return nil
}

View File

@ -0,0 +1,20 @@
package datastar
import "testing"
func TestAllValidFragmentMergeTypes(t *testing.T) {
var err error
for _, validType := range ValidFragmentMergeTypes {
if _, err = FragmentMergeTypeFromString(string(validType)); err != nil {
t.Errorf("Expected %v to be a valid fragment merge type, but it was rejected: %v", validType, err)
}
}
if _, err = FragmentMergeTypeFromString(""); err == nil {
t.Errorf("Expected an empty string to be an invalid fragment merge type, but it was accepted")
}
if _, err = FragmentMergeTypeFromString("fakeType"); err == nil {
t.Errorf("Expected a fake type to be an invalid fragment merge type, but it was accepted")
}
}

View File

@ -0,0 +1,42 @@
package datastar
import (
"encoding/json"
"fmt"
)
// MarshalAndMergeSignals is a convenience method for [see.MergeSignals].
// It marshals a given signals struct into JSON and
// emits a [EventTypeMergeSignals] event.
func (sse *ServerSentEventGenerator) MarshalAndMergeSignals(signals any, opts ...MergeSignalsOption) error {
b, err := json.Marshal(signals)
if err != nil {
panic(err)
}
if err := sse.MergeSignals(b, opts...); err != nil {
return fmt.Errorf("failed to merge signals: %w", err)
}
return nil
}
// MarshalAndMergeSignalsIfMissing is a convenience method for [see.MarshalAndMergeSignals].
// It is equivalent to calling [see.MarshalAndMergeSignals] with [see.WithOnlyIfMissing(true)] option.
func (sse *ServerSentEventGenerator) MarshalAndMergeSignalsIfMissing(signals any, opts ...MergeSignalsOption) error {
if err := sse.MarshalAndMergeSignals(
signals,
append(opts, WithOnlyIfMissing(true))...,
); err != nil {
return fmt.Errorf("failed to merge signals if missing: %w", err)
}
return nil
}
// MergeSignalsIfMissingRaw is a convenience method for [see.MergeSignals].
// It is equivalent to calling [see.MergeSignals] with [see.WithOnlyIfMissing(true)] option.
func (sse *ServerSentEventGenerator) MergeSignalsIfMissingRaw(signalsJSON string) error {
if err := sse.MergeSignals([]byte(signalsJSON), WithOnlyIfMissing(true)); err != nil {
return fmt.Errorf("failed to merge signals if missing: %w", err)
}
return nil
}

View File

@ -0,0 +1,147 @@
package datastar
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/valyala/bytebufferpool"
)
var (
// ErrNoPathsProvided is returned when no paths were provided for // for [sse.RemoveSignals] call.
ErrNoPathsProvided = errors.New("no paths provided")
)
// mergeSignalsOptions holds configuration options for merging signals.
type mergeSignalsOptions struct {
EventID string
RetryDuration time.Duration
OnlyIfMissing bool
}
// MergeSignalsOption configures one [EventTypeMergeSignals] event.
type MergeSignalsOption func(*mergeSignalsOptions)
// WithMergeSignalsEventID configures an optional event ID for the signals merge event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithMergeSignalsEventID(id string) MergeSignalsOption {
return func(o *mergeSignalsOptions) {
o.EventID = id
}
}
// WithMergeSignalsRetryDuration overrides the [DefaultSseRetryDuration] for signal merging.
func WithMergeSignalsRetryDuration(retryDuration time.Duration) MergeSignalsOption {
return func(o *mergeSignalsOptions) {
o.RetryDuration = retryDuration
}
}
// WithOnlyIfMissing instructs the client to only merge signals if they are missing.
func WithOnlyIfMissing(onlyIfMissing bool) MergeSignalsOption {
return func(o *mergeSignalsOptions) {
o.OnlyIfMissing = onlyIfMissing
}
}
// MergeSignals sends a [EventTypeMergeSignals] to the client.
// Requires a JSON-encoded payload.
func (sse *ServerSentEventGenerator) MergeSignals(signalsContents []byte, opts ...MergeSignalsOption) error {
options := &mergeSignalsOptions{
EventID: "",
RetryDuration: DefaultSseRetryDuration,
OnlyIfMissing: false,
}
for _, opt := range opts {
opt(options)
}
dataRows := make([]string, 0, 32)
if options.OnlyIfMissing {
dataRows = append(dataRows, OnlyIfMissingDatalineLiteral+strconv.FormatBool(options.OnlyIfMissing))
}
lines := bytes.Split(signalsContents, newLineBuf)
for _, line := range lines {
dataRows = append(dataRows, SignalsDatalineLiteral+string(line))
}
sendOptions := make([]SSEEventOption, 0, 2)
if options.EventID != "" {
sendOptions = append(sendOptions, WithSSEEventId(options.EventID))
}
if options.RetryDuration != DefaultSseRetryDuration {
sendOptions = append(sendOptions, WithSSERetryDuration(options.RetryDuration))
}
if err := sse.Send(
EventTypeMergeSignals,
dataRows,
sendOptions...,
); err != nil {
return fmt.Errorf("failed to send merge signals: %w", err)
}
return nil
}
// RemoveSignals sends a [EventTypeRemoveSignals] event to the client.
// Requires a non-empty list of paths.
func (sse *ServerSentEventGenerator) RemoveSignals(paths ...string) error {
if len(paths) == 0 {
return ErrNoPathsProvided
}
dataRows := make([]string, 0, len(paths))
for _, path := range paths {
dataRows = append(dataRows, PathsDatalineLiteral+path)
}
if err := sse.Send(
EventTypeRemoveSignals,
dataRows,
); err != nil {
return fmt.Errorf("failed to send remove signals: %w", err)
}
return nil
}
// ReadSignals extracts Datastar signals from
// an HTTP request and unmarshals them into the signals target,
// which should be a pointer to a struct.
//
// Expects signals in [URL.Query] for [http.MethodGet] requests.
// Expects JSON-encoded signals in [Request.Body] for other request methods.
func ReadSignals(r *http.Request, signals any) error {
var dsInput []byte
if r.Method == "GET" {
dsJSON := r.URL.Query().Get(DatastarKey)
if dsJSON == "" {
return nil
} else {
dsInput = []byte(dsJSON)
}
} else {
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
if _, err := buf.ReadFrom(r.Body); err != nil {
if err == http.ErrBodyReadAfterClose {
return fmt.Errorf("body already closed, are you sure you created the SSE ***AFTER*** the ReadSignals? %w", err)
}
return fmt.Errorf("failed to read body: %w", err)
}
dsInput = buf.Bytes()
}
if err := json.Unmarshal(dsInput, signals); err != nil {
return fmt.Errorf("failed to unmarshal: %w", err)
}
return nil
}

View File

@ -0,0 +1,294 @@
package datastar
import (
"strings"
"github.com/CAFxX/httpcompression/contrib/andybalholm/brotli"
"github.com/CAFxX/httpcompression/contrib/compress/gzip"
"github.com/CAFxX/httpcompression/contrib/compress/zlib"
"github.com/CAFxX/httpcompression/contrib/klauspost/zstd"
zstd_opts "github.com/klauspost/compress/zstd"
"github.com/CAFxX/httpcompression"
)
// CompressionStrategy indicates the strategy for selecting the compression algorithm.
type CompressionStrategy string
const (
// ClientPriority indicates that the client's preferred compression algorithm
// should be used if possible.
ClientPriority CompressionStrategy = "client_priority"
// ServerPriority indicates that the server's preferred compression algorithm
// should be used.
ServerPriority CompressionStrategy = "server_priority"
// Forced indicates that the first provided compression
// algorithm must be used regardless of client or server preferences.
Forced CompressionStrategy = "forced"
)
// Compressor pairs a [httpcompression.CompressorProvider]
// with an encoding HTTP content type.
type Compressor struct {
Encoding string
Compressor httpcompression.CompressorProvider
}
// compressionOptions holds all the data for server-sent events
// message compression configuration initiated by [CompressionOption]s.
type compressionOptions struct {
CompressionStrategy CompressionStrategy
ClientEncodings []string
Compressors []Compressor
}
// CompressionOption configures server-sent events
// message compression.
type CompressionOption func(*compressionOptions)
// GzipOption configures the Gzip compression algorithm.
type GzipOption func(*gzip.Options)
// WithGzipLevel determines the algorithm's compression level.
// Higher values result in smaller output at the cost of higher CPU usage.
//
// Choose one of the following levels:
// - [gzip.NoCompression]
// - [gzip.BestSpeed]
// - [gzip.BestCompression]
// - [gzip.DefaultCompression]
// - [gzip.HuffmanOnly]
func WithGzipLevel(level int) GzipOption {
return func(opts *gzip.Options) {
opts.Level = level
}
}
// WithGzip appends a [Gzip] compressor to the list of compressors.
//
// [Gzip]: https://en.wikipedia.org/wiki/Gzip
func WithGzip(opts ...GzipOption) CompressionOption {
return func(cfg *compressionOptions) {
// set default options
options := gzip.Options{
Level: gzip.DefaultCompression,
}
// Apply all provided options.
for _, opt := range opts {
opt(&options)
}
gzipCompressor, _ := gzip.New(options)
compressor := Compressor{
Encoding: gzip.Encoding,
Compressor: gzipCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// DeflateOption configures the Deflate compression algorithm.
type DeflateOption func(*zlib.Options)
// WithDeflateLevel determines the algorithm's compression level.
// Higher values result in smaller output at the cost of higher CPU usage.
//
// Choose one of the following levels:
// - [zlib.NoCompression]
// - [zlib.BestSpeed]
// - [zlib.BestCompression]
// - [zlib.DefaultCompression]
// - [zlib.HuffmanOnly]
func WithDeflateLevel(level int) DeflateOption {
return func(opts *zlib.Options) {
opts.Level = level
}
}
// WithDeflateDictionary sets the dictionary used by the algorithm.
// This can improve compression ratio for repeated data.
func WithDeflateDictionary(dict []byte) DeflateOption {
return func(opts *zlib.Options) {
opts.Dictionary = dict
}
}
// WithDeflate appends a [Deflate] compressor to the list of compressors.
//
// [Deflate]: https://en.wikipedia.org/wiki/Deflate
func WithDeflate(opts ...DeflateOption) CompressionOption {
return func(cfg *compressionOptions) {
options := zlib.Options{
Level: zlib.DefaultCompression,
}
for _, opt := range opts {
opt(&options)
}
zlibCompressor, _ := zlib.New(options)
compressor := Compressor{
Encoding: zlib.Encoding,
Compressor: zlibCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// BrotliOption configures the Brotli compression algorithm.
type BrotliOption func(*brotli.Options)
// WithBrotliLevel determines the algorithm's compression level.
// Higher values result in smaller output at the cost of higher CPU usage.
// Fastest compression level is 0. Best compression level is 11.
// Defaults to 6.
func WithBrotliLevel(level int) BrotliOption {
return func(opts *brotli.Options) {
opts.Quality = level
}
}
// WithBrotliLGWin the sliding window size for Brotli compression
// algorithm. Select a value between 10 and 24.
// Defaults to 0, indicating automatic window size selection based on compression quality.
func WithBrotliLGWin(lgwin int) BrotliOption {
return func(opts *brotli.Options) {
opts.LGWin = lgwin
}
}
// WithBrotli appends a [Brotli] compressor to the list of compressors.
//
// [Brotli]: https://en.wikipedia.org/wiki/Brotli
func WithBrotli(opts ...BrotliOption) CompressionOption {
return func(cfg *compressionOptions) {
options := brotli.Options{
Quality: brotli.DefaultCompression,
}
for _, opt := range opts {
opt(&options)
}
brotliCompressor, _ := brotli.New(options)
compressor := Compressor{
Encoding: brotli.Encoding,
Compressor: brotliCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// WithZstd appends a [Zstd] compressor to the list of compressors.
//
// [Zstd]: https://en.wikipedia.org/wiki/Zstd
func WithZstd(opts ...zstd_opts.EOption) CompressionOption {
return func(cfg *compressionOptions) {
zstdCompressor, _ := zstd.New(opts...)
compressor := Compressor{
Encoding: zstd.Encoding,
Compressor: zstdCompressor,
}
cfg.Compressors = append(cfg.Compressors, compressor)
}
}
// WithClientPriority sets the compression strategy to [ClientPriority].
// The compression algorithm will be selected based on the
// client's preference from the list of included compressors.
func WithClientPriority() CompressionOption {
return func(cfg *compressionOptions) {
cfg.CompressionStrategy = ClientPriority
}
}
// WithServerPriority sets the compression strategy to [ServerPriority].
// The compression algorithm will be selected based on the
// server's preference from the list of included compressors.
func WithServerPriority() CompressionOption {
return func(cfg *compressionOptions) {
cfg.CompressionStrategy = ServerPriority
}
}
// WithForced sets the compression strategy to [Forced].
// The first compression algorithm will be selected
// from the list of included compressors.
func WithForced() CompressionOption {
return func(cfg *compressionOptions) {
cfg.CompressionStrategy = Forced
}
}
// WithCompression adds compression to server-sent event stream.
func WithCompression(opts ...CompressionOption) SSEOption {
return func(sse *ServerSentEventGenerator) {
cfg := &compressionOptions{
CompressionStrategy: ClientPriority,
ClientEncodings: parseEncodings(sse.acceptEncoding),
}
// apply options
for _, opt := range opts {
opt(cfg)
}
// set defaults
if len(cfg.Compressors) == 0 {
WithBrotli()(cfg)
WithZstd()(cfg)
WithGzip()(cfg)
WithDeflate()(cfg)
}
switch cfg.CompressionStrategy {
case ClientPriority:
for _, clientEnc := range cfg.ClientEncodings {
for _, comp := range cfg.Compressors {
if comp.Encoding == clientEnc {
sse.w = comp.Compressor.Get(sse.w)
sse.encoding = comp.Encoding
return
}
}
}
case ServerPriority:
for _, comp := range cfg.Compressors {
for _, clientEnc := range cfg.ClientEncodings {
if comp.Encoding == clientEnc {
sse.w = comp.Compressor.Get(sse.w)
sse.encoding = comp.Encoding
return
}
}
}
case Forced:
if len(cfg.Compressors) > 0 {
sse.w = cfg.Compressors[0].Compressor.Get(sse.w)
sse.encoding = cfg.Compressors[0].Encoding
}
}
}
}
func parseEncodings(header string) []string {
parts := strings.Split(header, ",")
var tokens []string
for _, part := range parts {
token := strings.SplitN(strings.TrimSpace(part), ";", 2)[0]
if token != "" {
tokens = append(tokens, token)
}
}
return tokens
}

View File

@ -0,0 +1,212 @@
package datastar
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"sync"
"time"
"github.com/valyala/bytebufferpool"
)
// ServerSentEventGenerator streams events into
// an [http.ResponseWriter]. Each event is flushed immediately.
type ServerSentEventGenerator struct {
ctx context.Context
mu *sync.Mutex
w io.Writer
rc *http.ResponseController
shouldLogPanics bool
encoding string
acceptEncoding string
}
// SSEOption configures the initialization of an
// HTTP Server-Sent Event stream.
type SSEOption func(*ServerSentEventGenerator)
// NewSSE upgrades an [http.ResponseWriter] to an HTTP Server-Sent Event stream.
// The connection is kept alive until the context is canceled or the response is closed by returning from the handler.
// Run an event loop for persistent streaming.
func NewSSE(w http.ResponseWriter, r *http.Request, opts ...SSEOption) *ServerSentEventGenerator {
rc := http.NewResponseController(w)
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", "text/event-stream")
if r.ProtoMajor == 1 {
w.Header().Set("Connection", "keep-alive")
}
sseHandler := &ServerSentEventGenerator{
ctx: r.Context(),
mu: &sync.Mutex{},
w: w,
rc: rc,
shouldLogPanics: true,
acceptEncoding: r.Header.Get("Accept-Encoding"),
}
// apply options
for _, opt := range opts {
opt(sseHandler)
}
// set compression encoding
if sseHandler.encoding != "" {
w.Header().Set("Content-Encoding", sseHandler.encoding)
}
// flush headers
if err := rc.Flush(); err != nil {
// Below panic is a deliberate choice as it should never occur and is an environment issue.
// https://crawshaw.io/blog/go-and-sqlite
// In Go, errors that are part of the standard operation of a program are returned as values.
// Programs are expected to handle errors.
panic(fmt.Sprintf("response writer failed to flush: %v", err))
}
return sseHandler
}
// Context returns the context associated with the upgraded connection.
// It is equivalent to calling [request.Context].
func (sse *ServerSentEventGenerator) Context() context.Context {
return sse.ctx
}
// serverSentEventData holds event configuration data for
// [SSEEventOption]s.
type serverSentEventData struct {
Type EventType
EventID string
Data []string
RetryDuration time.Duration
}
// SSEEventOption modifies one server-sent event.
type SSEEventOption func(*serverSentEventData)
// WithSSEEventId configures an optional event ID for one server-sent event.
// The client message field [lastEventId] will be set to this value.
// If the next event does not have an event ID, the last used event ID will remain.
//
// [lastEventId]: https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/lastEventId
func WithSSEEventId(id string) SSEEventOption {
return func(e *serverSentEventData) {
e.EventID = id
}
}
// WithSSERetryDuration overrides the [DefaultSseRetryDuration] for
// one server-sent event.
func WithSSERetryDuration(retryDuration time.Duration) SSEEventOption {
return func(e *serverSentEventData) {
e.RetryDuration = retryDuration
}
}
var (
eventLinePrefix = []byte("event: ")
idLinePrefix = []byte("id: ")
retryLinePrefix = []byte("retry: ")
dataLinePrefix = []byte("data: ")
)
func writeJustError(w io.Writer, b []byte) (err error) {
_, err = w.Write(b)
return err
}
// Send emits a server-sent event to the client. Method is safe for
// concurrent use.
func (sse *ServerSentEventGenerator) Send(eventType EventType, dataLines []string, opts ...SSEEventOption) error {
sse.mu.Lock()
defer sse.mu.Unlock()
// create the event
evt := serverSentEventData{
Type: eventType,
Data: dataLines,
RetryDuration: DefaultSseRetryDuration,
}
// apply options
for _, opt := range opts {
opt(&evt)
}
buf := bytebufferpool.Get()
defer bytebufferpool.Put(buf)
// write event type
if err := errors.Join(
writeJustError(buf, eventLinePrefix),
writeJustError(buf, []byte(evt.Type)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write event type: %w", err)
}
// write id if needed
if evt.EventID != "" {
if err := errors.Join(
writeJustError(buf, idLinePrefix),
writeJustError(buf, []byte(evt.EventID)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write id: %w", err)
}
}
// write retry if needed
if evt.RetryDuration.Milliseconds() > 0 && evt.RetryDuration.Milliseconds() != DefaultSseRetryDuration.Milliseconds() {
retry := int(evt.RetryDuration.Milliseconds())
retryStr := strconv.Itoa(retry)
if err := errors.Join(
writeJustError(buf, retryLinePrefix),
writeJustError(buf, []byte(retryStr)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write retry: %w", err)
}
}
// write data lines
for _, d := range evt.Data {
if err := errors.Join(
writeJustError(buf, dataLinePrefix),
writeJustError(buf, []byte(d)),
writeJustError(buf, newLineBuf),
); err != nil {
return fmt.Errorf("failed to write data: %w", err)
}
}
// write double newlines to separate events
if err := writeJustError(buf, doubleNewLineBuf); err != nil {
return fmt.Errorf("failed to write newline: %w", err)
}
// copy the buffer to the response writer
if _, err := buf.WriteTo(sse.w); err != nil {
return fmt.Errorf("failed to write to response writer: %w", err)
}
// flush the write if its a compressing writer
if f, ok := sse.w.(flusher); ok {
if err := f.Flush(); err != nil {
return fmt.Errorf("failed to flush compressing writer: %w", err)
}
}
if err := sse.rc.Flush(); err != nil {
return fmt.Errorf("failed to flush data: %w", err)
}
// log.Print(NewLine + buf.String())
return nil
}

View File

@ -0,0 +1,15 @@
package datastar
const (
NewLine = "\n"
DoubleNewLine = "\n\n"
)
var (
newLineBuf = []byte(NewLine)
doubleNewLineBuf = []byte(DoubleNewLine)
)
type flusher interface {
Flush() error
}

View File

@ -0,0 +1,23 @@
<!DOCTYPE html >
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CRUD Example</title>
<link rel="stylesheet" href="pico.blue.css">
<meta name="color-scheme" content="light dark">
<script type="module" src="datastar.js"></script>
</head>
<body class="container" data-theme="light">
<h1 style="margin-top: 1em; text-align:center">The CRUD Example</h1>
<main class="grid" data-theme="dark">
<div id="board" data-init="@get('board-content')"></div>
</main>
</body>
</html>

4
crud/.server/getuser.sql Normal file
View File

@ -0,0 +1,4 @@
SELECT u.id, u.name, u.username, u.email, u.phone, u.website, a.street, a.suite, a.zipcode, a.city, c.name as company, c.catch_phrase, c.bs
FROM user u
JOIN company c ON u.company_id = c.id
JOIN address a ON u.address_id = a.id;

24
crud/.server/go.mod Normal file
View File

@ -0,0 +1,24 @@
module crud
go 1.25.0
require (
github.com/CAFxX/httpcompression v0.0.9
github.com/klauspost/compress v1.18.1
github.com/valyala/bytebufferpool v1.0.0
modernc.org/sqlite v1.40.0
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

82
crud/.server/go.sum Normal file
View File

@ -0,0 +1,82 @@
github.com/CAFxX/httpcompression v0.0.9 h1:0ue2X8dOLEpxTm8tt+OdHcgA+gbDge0OqFQWGKSqgrg=
github.com/CAFxX/httpcompression v0.0.9/go.mod h1:XX8oPZA+4IDcfZ0A71Hz0mZsv/YJOgYygkFhizVPilM=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

251
crud/.server/main_dev.go Normal file
View File

@ -0,0 +1,251 @@
//go:build !prod
// +build !prod
package main
import (
"context"
"crud/datastar"
"crud/sqlite"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"time"
)
const (
exitCodeErr = 1
exitCodeInterrupt = 2
)
//go:embed frontend
var frontend embed.FS
//go:embed templates
var templates embed.FS
var templ = template.Must(template.ParseFS(templates, "templates/*.html"))
// main is the entry point of the application.
// Its task is to check wether all execution conditions are fullfilled.
// Collecting information from the environment: flags, environment vars, configs.
// Calling the run() function.
func main() {
fmt.Println("Developement mode")
ctx := context.Background()
// logging
logFileName := "./crud.log"
logFile, err := os.OpenFile(logFileName, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Printf("error opening file: %v", err)
os.Exit(exitCodeErr)
}
defer logFile.Close()
log.SetOutput(logFile)
// database
dbName := "./user.db"
db := sqlite.New(dbName)
if err != nil {
log.Printf("Failed to open %s database: %v", dbName, err)
os.Exit(exitCodeErr)
}
// run the app
if err := run(db, ctx); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(exitCodeErr)
}
}
// Setting up all dependencies
// Creating the server (a central http handler)
func run(db *sqlite.Database, ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
err := db.Open(ctx)
if err != nil {
log.Printf("Failed to open %s database: %v", db.Name(), err)
os.Exit(exitCodeErr)
}
defer db.Close()
server := NewServer(db)
httpServer := &http.Server{
Addr: ":8080",
Handler: server,
}
go func() {
log.Printf("listening on %s\n", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
// make a new context for the Shutdown (thanks Alessandro Rosetti)
// shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
}
log.Printf("shut down http server on %s\n", httpServer.Addr)
}()
wg.Wait()
return nil
}
// The NewServer constructor is responsible for all the top-level HTTP stuff that applies to all endpoints, like CORS, auth middleware, and logging:
func NewServer(db *sqlite.Database) http.Handler {
mux := http.NewServeMux()
static, err := fs.Sub(frontend, "frontend")
if err != nil {
log.Fatal(err)
}
// Print the embedded filesystem tree
fmt.Println("Embedded frontend filesystem tree:")
err = printFSTree(static, ".", 0)
if err != nil {
log.Printf("Error printing filesystem tree: %v\n", err)
}
fmt.Println("--- End of tree ---")
addRoutes(
mux,
db,
static,
// templates,
)
mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(1000 * time.Millisecond)
// original: defer ticker.Stop()
defer func() {
fmt.Println("defer executed")
ticker.Stop()
}()
sse := datastar.NewSSE(w, r)
for {
select {
case <-r.Context().Done():
slog.Debug("Client connection closed")
return
case <-ticker.C:
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
slog.Error("Error generating random bytes: ", slog.String("error", err.Error()))
return
}
hexString := hex.EncodeToString(bytes)
frag := fmt.Sprintf(`<span id="feed" style="color:#%s;border:1px solid #%s;border-radius:0.25rem;padding:1rem;">%s</span>`, hexString, hexString, hexString)
sse.MergeFragments(frag)
}
}
})
var handler http.Handler = mux
// handler = authMiddleware(handler)
handler = headerMiddleware(handler)
return handler
}
// printFSTree prints a tree-like structure of the given filesystem.
func printFSTree(efs fs.FS, root string, indentLevel int) error {
return fs.WalkDir(efs, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself for cleaner output if it's "."
if path == "." && root == "." {
return nil
}
indent := strings.Repeat("│ ", indentLevel)
connector := "├── "
// For the last item in a directory, use a different connector.
// This requires knowing if it's the last item, which fs.WalkDir doesn't directly provide.
// For simplicity, we'll use the same connector for all items.
// A more sophisticated approach would involve reading directory entries first.
fmt.Printf("%s%s%s\n", indent, connector, filepath.Base(path))
if d.IsDir() && path != root { // Avoid infinite recursion for the root itself if not handled carefully
// The WalkDir function handles recursion, so we don't need to call printFSTree recursively here.
// We adjust indentLevel based on path depth for visual representation.
// This simple indentation based on WalkDir's path might not be perfect for deep structures
// but gives a good overview.
}
return nil
})
}
// authMiddleware is a simple authentication middleware
// func authMiddleware(next http.Handler) http.Handler {
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// user, pass, ok := r.BasicAuth()
// if !ok || !validateUser(user, pass) {
// w.Header().Set("WWW-Authenticate", `Basic realm="Please enter your credentials"`)
// http.Error(w, "Unauthorized", http.StatusUnauthorized)
// return
// }
// next.ServeHTTP(w, r)
// })
// }
// authMiddleware is a simple authentication middleware
func headerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("Request URL:", r.URL.String())
fmt.Println("Request Headers:")
for key, values := range r.Header {
for _, value := range values {
if key == "Referer" || strings.HasPrefix(key, "Hx") {
fmt.Printf("%s: %s\n", key, value)
}
}
}
fmt.Println()
next.ServeHTTP(w, r)
})
}
// validateUser validates the user credentials
func validateUser(username, password string) bool {
// In a real application, these credentials should be stored securely.
return strings.EqualFold(username, "admin") && password == "password"
}

252
crud/.server/main_prod.go Normal file
View File

@ -0,0 +1,252 @@
//go:build prod
// +build prod
package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"log"
"log/slog"
"crud/datastar"
"crud/sqlite"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"time"
)
const (
exitCodeErr = 1
exitCodeInterrupt = 2
)
//go:embed frontend
var frontend embed.FS
//go:embed templates
var templates embed.FS
// var templ = template.Must(template.ParseFS(templates, "templates/*.html"))
// main is the entry point of the application.
// Its task is to check wether all execution conditions are fullfilled.
// Collecting information from the environment: flags, environment vars, configs.
// Calling the run() function.
func main() {
// fmt.Println("Production mode")
ctx := context.Background()
// logging
logFileName := "./mva.log"
logFile, err := os.OpenFile(logFileName, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Printf("error opening file: %v", err)
os.Exit(exitCodeErr)
}
defer logFile.Close()
log.SetOutput(logFile)
// database
dbName := "./user.db"
db := sqlite.New(dbName)
if err != nil {
log.Printf("Failed to open %s database: %v", dbName, err)
os.Exit(exitCodeErr)
}
// run the app
if err := run(db, ctx); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(exitCodeErr)
}
}
// Setting up all dependencies
// Creating the server (a central http handler)
func run(db *sqlite.Database, ctx context.Context) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
err := db.Open(ctx)
if err != nil {
log.Printf("Failed to open %s database: %v", db.Name(), err)
os.Exit(exitCodeErr)
}
defer db.Close()
server := NewServer(db)
httpServer := &http.Server{
Addr: ":8080",
Handler: server,
}
go func() {
log.Printf("listening on %s\n", httpServer.Addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error listening and serving: %s\n", err)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
// make a new context for the Shutdown (thanks Alessandro Rosetti)
// shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "error shutting down http server: %s\n", err)
}
log.Printf("shut down http server on %s\n", httpServer.Addr)
}()
wg.Wait()
return nil
}
// The NewServer constructor is responsible for all the top-level HTTP stuff that applies to all endpoints, like CORS, auth middleware, and logging:
func NewServer(db *sqlite.Database) http.Handler {
mux := http.NewServeMux()
static, err := fs.Sub(frontend, "frontend")
if err != nil {
log.Fatal(err)
}
// Print the embedded filesystem tree
fmt.Println("Embedded frontend filesystem tree:")
err = printFSTree(static, ".", 0)
if err != nil {
log.Printf("Error printing filesystem tree: %v\n", err)
}
fmt.Println("--- End of tree ---")
addRoutes(
mux,
static,
nil, // templ
)
mux.HandleFunc("GET /stream", func(w http.ResponseWriter, r *http.Request) {
ticker := time.NewTicker(1000 * time.Millisecond)
// original: defer ticker.Stop()
defer func() {
fmt.Println("defer executed")
ticker.Stop()
}()
sse := datastar.NewSSE(w, r)
for {
select {
case <-r.Context().Done():
slog.Debug("Client connection closed")
return
case <-ticker.C:
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
slog.Error("Error generating random bytes: ", slog.String("error", err.Error()))
return
}
hexString := hex.EncodeToString(bytes)
frag := fmt.Sprintf(`<span id="feed" style="color:#%s;border:1px solid #%s;border-radius:0.25rem;padding:1rem;">%s</span>`, hexString, hexString, hexString)
sse.MergeFragments(frag)
}
}
})
var handler http.Handler = mux
// handler = authMiddleware(handler)
handler = headerMiddleware(handler)
return handler
}
// printFSTree prints a tree-like structure of the given filesystem.
func printFSTree(efs fs.FS, root string, indentLevel int) error {
return fs.WalkDir(efs, root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself for cleaner output if it's "."
if path == "." && root == "." {
return nil
}
indent := strings.Repeat("│ ", indentLevel)
connector := "├── "
// For the last item in a directory, use a different connector.
// This requires knowing if it's the last item, which fs.WalkDir doesn't directly provide.
// For simplicity, we'll use the same connector for all items.
// A more sophisticated approach would involve reading directory entries first.
fmt.Printf("%s%s%s\n", indent, connector, filepath.Base(path))
if d.IsDir() && path != root { // Avoid infinite recursion for the root itself if not handled carefully
// The WalkDir function handles recursion, so we don't need to call printFSTree recursively here.
// We adjust indentLevel based on path depth for visual representation.
// This simple indentation based on WalkDir's path might not be perfect for deep structures
// but gives a good overview.
}
return nil
})
}
// authMiddleware is a simple authentication middleware
// func authMiddleware(next http.Handler) http.Handler {
// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// user, pass, ok := r.BasicAuth()
// if !ok || !validateUser(user, pass) {
// w.Header().Set("WWW-Authenticate", `Basic realm="Please enter your credentials"`)
// http.Error(w, "Unauthorized", http.StatusUnauthorized)
// return
// }
// next.ServeHTTP(w, r)
// })
// }
// authMiddleware is a simple authentication middleware
func headerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("Request URL:", r.URL.String())
fmt.Println("Request Headers:")
for key, values := range r.Header {
for _, value := range values {
if key == "Referer" || strings.HasPrefix(key, "Hx") {
fmt.Printf("%s: %s\n", key, value)
}
}
}
fmt.Println()
next.ServeHTTP(w, r)
})
}
// validateUser validates the user credentials
func validateUser(username, password string) bool {
// In a real application, these credentials should be stored securely.
return strings.EqualFold(username, "admin") && password == "password"
}

25
crud/.server/routes.go Normal file
View File

@ -0,0 +1,25 @@
package main
// This file is the one place in your application where all routes are listed.
import (
"crud/sqlite"
"io/fs"
"net/http"
)
// addRoutes combines the URL endpoints with the applications's services
// and dependencies and required middleware
func addRoutes(
mux *http.ServeMux,
database *sqlite.Database,
static fs.FS,
// templ *template.Template,
) {
mux.Handle("GET /", http.FileServer(http.FS(static)))
// mux.Handle("GET /tables", api.TableList(database))
// mux.Handle("GET /count", api.ProductCount(database))
// mux.Handle("GET /nutriments/{page}", api.DataNutriments(database, templ))
// mux.Handle("GET /products/{page}", api.DataProducts(database, templ))
// mux.Handle("GET /brandowner/{page}", api.DataBrandOwner(database, templ))
}

View File

@ -0,0 +1,614 @@
package sqlite // name the package as you see fit, it is intended to be vendored
import (
"bytes"
"database/sql"
"errors"
"context"
"fmt"
"os"
"strconv"
"text/template"
_ "modernc.org/sqlite"
)
/*
Package sqlite provides a simplified wrapper around the modernc.org/sqlite driver.
It aims to provide a convenient, developer-friendly interface for common database
operations, prioritizing ease of use with a map-based data exchange format (Record).
Key Concepts:
- Database Instance: A single `Database` struct instance manages the connection to
a specific database file or an in-memory database.
- Lifecycle: Use `New()` to create an instance, `Open()` or `OpenInMemory()`
to establish the connection, and `defer Close()` to release resources.
- Record Type: `type Record = map[string]any` is the primary type for exchanging
data with the database. Column names become map keys.
- Underlying DB Access: The `DB()` method provides access to the raw `*sql.DB`
object for operations not covered by the wrapper.
Features:
- Reading Data:
- `ReadTable(tablename string)`: Reads all rows and columns from a specified table.
- `ReadRecords(query string, args ...any)`: Executes a custom SQL SELECT query
with parameterized arguments and returns multiple records.
- `GetRecord(tablename string, idfield string, key any)`: Retrieves a single
record from a table based on a unique identifier.
- Writing Data:
- `UpsertRecord(tablename string, idfield string, record Record)`: Inserts a new
record or updates an existing one based on the value of the `idfield`.
Uses SQLite's `ON CONFLICT` clause.
- Supports partial updates: Only include fields you want to insert/update in the `Record`.
- Returns the full resulting record (including auto-generated IDs) using `RETURNING *`.
- Deleting Data:
- `DeleteRecord(tablename string, idfield string, id any)`: Deletes a single
record from a table based on its identifier.
- Metadata:
- `TableList()`: Lists all tables in the database.
- `Version()`: Gets the SQLite library version.
- `UserVersion()`: Gets the database's user_version PRAGMA.
Transaction Handling:
- `Begin()`: Starts a new database transaction, returning a `*Transaction` object.
- Chaining: Transaction methods (`GetRecord`, `UpsertRecord`, `DeleteRecord`, `Next`)
return the `*Transaction` object, allowing operations to be chained.
- Error Propagation: If any operation within a transaction chain fails, the error
is stored in the `Transaction` object (`tx.Err()`), and subsequent chained
operations become no-ops.
- `Next(action Action)`: Allows executing custom logic within the transaction
by providing a function that receives the raw `*sql.Tx`.
- `End()`: Finalizes the transaction. If `tx.Err()` is non-nil, it performs a
ROLLBACK; otherwise, it performs a COMMIT. Returns the accumulated error.
Helper Functions:
- `ValueT any`: A generic helper to safely extract
and type-assert a value from a `Record` map.
- `NoRowsOk([]Record, error)`: A helper to wrap calls that might return
`sql.ErrNoRows` and treat that specific error as a non-error case, returning
nil records and a nil error.
Prerequisites:
- For `UpsertRecord` to function correctly, the target table must have a unique
index defined on the specified `idfield`.
- It is highly recommended that the `idfield` is an `INTEGER PRIMARY KEY AUTOINCREMENT`
to leverage SQLite's built-in ID generation and efficient lookups.
Shortcomings and Important Considerations:
- SQL Injection Risk:
- Identifiers: Table names, field names, and record keys (used as field names)
are validated to contain only alphanumeric characters and underscores. They are
also quoted by the library. This significantly mitigates SQL injection risks
through identifiers. However, the caller MUST still ensure that these identifiers
refer to the *intended* database objects.
- Query Structure: For `ReadRecords` and `Transaction.Next` actions, if the raw
SQL query string itself is constructed from untrusted user input, it remains a
potential SQL injection vector. Parameterization is used by this library (and
`database/sql`) only for *values*, not for the query structure or identifiers
within a user-provided query string.
- Simplicity over Edge Cases: This is a simplified layer. More complex scenarios
or advanced SQLite features might require using the underlying `*sql.DB` object
via the `DB()` method.
- Room for Improvement: As a fresh implementation, there is potential for
further optimization and refinement.
Implementation Details:
- Uses the `modernc.org/sqlite` driver.
- SQL commands for `UpsertRecord` are dynamically generated using Go's `text/template`.
- Internal interfaces (`iquery`, `iExec`) are used to allow functions like `upsert`
and `deleteRecord` to work seamlessly with both `*sql.DB` and `*sql.Tx`.
Unit Tests:
- The package includes unit tests (`database_test.go`, `transaction_test.go`, `helpers_test.go`)
covering core functionality and transaction handling.
*/
// ErrInvalidIdentifier is returned when a table or column name contains disallowed characters.
var ErrInvalidIdentifier = errors.New("invalid identifier: contains disallowed characters")
// This is the data type to exchange data with the database
type Record = map[string]any
type Database struct {
databaseName string
database *sql.DB
}
func New(DBName string) *Database {
return &Database{databaseName: DBName}
}
func (d *Database) Close() error {
return d.database.Close()
}
// provides access to the internal database object
func (d *Database) DB() *sql.DB {
return d.database
}
func (d *Database) Name() string {
return d.databaseName
}
// basePragmas returns a string of common PRAGMA settings for SQLite.
// It excludes user_version, which is typically managed by schema migrations.
func basePragmas() string {
return `
PRAGMA page_size = 4096;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
`
}
func (d *Database) Open(ctx context.Context) (err error) {
d.database, err = openSqliteDB(ctx, d.databaseName)
return err
}
func (d *Database) OpenInMemory(ctx context.Context) (err error) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
return err
}
// Apply base PRAGMAs for consistency in in-memory databases.
_, err = db.ExecContext(ctx, basePragmas())
d.database = db
return err
}
func openSqliteDB(ctx context.Context, databasefilename string) (*sql.DB, error) {
_, err := os.Stat(databasefilename)
if errors.Is(err, os.ErrNotExist) {
return createDB(ctx, databasefilename)
}
if err != nil {
return nil, err
}
return sql.Open("sqlite", databasefilename)
}
func createDB(ctx context.Context, dbfileName string) (*sql.DB, error) {
// Apply base pragmas and set initial user_version for new database files.
query := basePragmas() + "PRAGMA user_version = 1;\n"
db, err := sql.Open("sqlite", dbfileName)
if err != nil {
return nil, err
}
_, err = db.ExecContext(ctx, query)
if err != nil {
db.Close() // Best effort to close if ExecContext fails
os.Remove(dbfileName) // Best effort to remove partially created file
return nil, err
}
return db, nil
}
func (d *Database) TableList(ctx context.Context) (result []Record, err error) {
return d.ReadRecords(ctx, "select name from sqlite_master where type='table';")
}
func (d *Database) ReadTable(ctx context.Context, tablename string) (result []Record, err error) {
if !isValidIdentifier(tablename) {
return nil, fmt.Errorf("ReadTable: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
return d.ReadRecords(ctx, fmt.Sprintf("select * from \"%s\";", tablename)) // Use double quotes for identifiers
}
func (d *Database) ReadRecords(ctx context.Context, query string, args ...any) (result []Record, err error) {
// Note: For ReadRecords, the query string itself is provided by the caller.
// The library cannot validate the structure of this query beyond what the driver does.
// The SQL injection caveat for arbitrary query strings remains critical here.
rows, err := d.DB().QueryContext(ctx, query, args...)
if err != nil {
return result, err
}
defer rows.Close()
return Rows2records(rows)
}
func (d *Database) GetRecord(ctx context.Context, tablename string, idfield string, key any) (result Record, err error) {
if !isValidIdentifier(tablename) {
return nil, fmt.Errorf("GetRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
if !isValidIdentifier(idfield) {
return nil, fmt.Errorf("GetRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
}
query := fmt.Sprintf("select * from \"%s\" where \"%s\" = ?;", tablename, idfield) // Quote identifiers
res, err := d.DB().QueryContext(ctx, query, key)
if err != nil {
return result, err
}
defer res.Close()
return Rows2record(res)
}
func (d *Database) UpsertRecord(ctx context.Context, tablename string, idfield string, record Record) (result Record, err error) {
if !isValidIdentifier(tablename) {
return nil, fmt.Errorf("UpsertRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
if !isValidIdentifier(idfield) {
return nil, fmt.Errorf("UpsertRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
}
return upsert(ctx, d.DB(), tablename, idfield, record)
}
func (d *Database) DeleteRecord(ctx context.Context, tablename string, idfield string, id any) (err error) {
// Validation for tablename and idfield will be done by deleteRecord internal helper
// to ensure consistency for both Database and Transaction calls.
return deleteRecord(ctx, d.DB(), tablename, idfield, id)
}
// *sql.DB and *sql.Tx both have a method named 'Query',
// this way they can both be passed into upsert and deleteRecord function
type iqueryContext interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
// iExec is an interface satisfied by both *sql.DB and *sql.Tx for Exec method
type iExecContext interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
func upsert(ctx context.Context, q iqueryContext, tablename string, idfield string, record Record) (result Record, err error) {
// tablename and idfield are assumed to be validated by the public-facing methods (Database.UpsertRecord, Transaction.UpsertRecord)
fields := []string{}
data := []any{}
for k, v := range record {
if !isValidIdentifier(k) {
return nil, fmt.Errorf("upsert: %w: field name '%s'", ErrInvalidIdentifier, k)
}
fields = append(fields, k)
data = append(data, v)
}
// Ensure idfield is part of the record if it's used for conflict and update,
// or handle cases where it might only be for conflict target and not in SET.
// The current buildUpsertCommand uses all fields from the record for the SET clause.
if _, present := record[idfield]; !present && len(record) > 0 {
// This situation is complex: if idfield is not in the record,
// it implies it might be auto-generated on INSERT, but for UPDATE,
// it's needed to identify the row. The ON CONFLICT target uses idfield.
// The current template includes all record fields in the SET clause.
// If idfield is not in record, it won't be in the SET clause unless explicitly added.
// For simplicity and current template, we assume if idfield is for update, it should be in the record.
}
if len(fields) == 0 {
return nil, errors.New("UpsertRecord: input record cannot be empty")
}
query, err := buildUpsertCommand(tablename, idfield, fields)
if err != nil {
return result, err
}
res, err := q.QueryContext(ctx, query, data...) // res contains the full record - see SQLite: RETURNING *
if err != nil {
return result, err
}
defer res.Close()
return Rows2record(res)
}
func deleteRecord(ctx context.Context, e iExecContext, tablename string, idfield string, id any) (err error) {
if !isValidIdentifier(tablename) {
return fmt.Errorf("deleteRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
}
if !isValidIdentifier(idfield) {
return fmt.Errorf("deleteRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
}
query := fmt.Sprintf("DELETE FROM \"%s\" WHERE \"%s\" = ?;", tablename, idfield)
_, err = e.ExecContext(ctx, query, id)
// Note: err could be sql.ErrNoRows if the driver/db supports it for Exec,
// or nil if delete affected 0 rows. Caller might want to check result.RowsAffected().
// For simplicity here, we just return the error from Exec.
return err
}
func buildUpsertCommand(tablename string, idfield string, fields []string) (result string, err error) {
// Assumes tablename, idfield, and all elements in fields are already validated
// by the calling function (e.g., upsert).
// And that fields is not empty.
pname := map[string]string{} // assign correct index for parameter name
// parameter position, starts at 1 in sql! So it needs to be calculated by function pname inside template
for i, k := range fields {
pname[k] = strconv.Itoa(i + 1)
}
funcMap := template.FuncMap{
"pname": func(fieldname string) string {
return pname[fieldname]
},
}
tableDef := struct {
Tablename string
KeyField string
LastField int
FieldNames []string
}{
Tablename: tablename,
KeyField: idfield,
LastField: len(fields) - 1,
FieldNames: fields,
}
var templString = `{{$last := .LastField}}INSERT INTO "{{ .Tablename }}"({{ range $i,$el := .FieldNames }} "{{$el}}"{{if ne $i $last}},{{end}}{{end}})
VALUES({{ range $i,$el := .FieldNames }} ?{{pname $el}}{{if ne $i $last}},{{end}}{{end}})
ON CONFLICT("{{ .Tablename }}"."{{.KeyField}}")
DO UPDATE SET {{ range $i,$el := .FieldNames }}"{{$el}}"= ?{{pname $el}}{{if ne $i $last}},{{end}}{{end}}
RETURNING *;`
dbTempl, err := template.New("upsertDB").Funcs(funcMap).Parse(templString)
if err != nil {
return result, err
}
var templBytes bytes.Buffer
err = dbTempl.Execute(&templBytes, tableDef)
if err != nil {
return result, err
}
return templBytes.String(), nil
}
func Rows2record(rows *sql.Rows) (Record, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
values := make([]any, len(columns))
valuePtrs := make([]any, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
result := Record{}
if !rows.Next() {
if err := rows.Err(); err != nil { // Check for errors during iteration attempt
return nil, err
}
return nil, sql.ErrNoRows // Standard error for no rows
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
for i, col := range columns {
result[col] = values[i]
}
// Check for errors encountered during iteration (e.g., if Next() was called multiple times).
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func Rows2records(rows *sql.Rows) ([]Record, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
recLength := len(columns)
results := []Record{}
for rows.Next() {
valuePtrs := make([]any, recLength)
values := make([]any, recLength)
for i := range values {
valuePtrs[i] = &values[i]
}
record := Record{}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
for i, col := range columns {
record[col] = values[i]
}
results = append(results, record)
}
// Check for errors encountered during iteration.
if err := rows.Err(); err != nil {
return nil, err
}
if len(results) == 0 {
// For a function returning a slice, an empty slice and nil error is often preferred for "no rows".
// However, if the expectation is that Rows2records is used where rows *should* exist, sql.ErrNoRows is appropriate.
return nil, sql.ErrNoRows // Or: return []Record{}, nil if empty slice is the desired "no rows" outcome
}
return results, nil
}
func (d *Database) Version(ctx context.Context) (string, error) {
var version string
err := d.DB().QueryRowContext(ctx, "SELECT sqlite_version();").Scan(&version)
return version, err
}
func (d *Database) UserVersion(ctx context.Context) (int64, error) {
var result int64
// PRAGMA user_version; returns a single row with a single column named "user_version".
// QueryRow().Scan() is appropriate here.
err := d.DB().QueryRowContext(ctx, "PRAGMA user_version;").Scan(&result)
return result, err
}
func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) *Transaction {
tx, err := d.database.BeginTx(ctx, opts)
return &Transaction{tx, err}
}
type Transaction struct {
tx *sql.Tx
err error
}
// Err returns the current error state of the transaction.
func (t *Transaction) Err() error {
return t.err
}
type Action func(ctx context.Context, tx *sql.Tx) error
func (t *Transaction) Next(ctx context.Context, action Action) *Transaction {
if t.err != nil {
return t
}
t.err = action(ctx, t.tx)
return t
}
func (t *Transaction) End() error {
if t.tx == nil { // Transaction was never begun or already ended
return t.err // Return any prior error
}
if t.err != nil {
err := t.tx.Rollback() // Rollback does not take context
if err != nil {
t.err = errors.Join(t.err, err)
}
return t.err
}
t.err = t.tx.Commit()
return t.err
}
func (t *Transaction) GetRecord(ctx context.Context, tablename string, idfield string, key any, output Record) *Transaction {
if !isValidIdentifier(tablename) {
t.err = fmt.Errorf("Transaction.GetRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
return t
}
if !isValidIdentifier(idfield) {
t.err = fmt.Errorf("Transaction.GetRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
return t
}
if t.err != nil {
return t
}
query := fmt.Sprintf("select * from \"%s\" where \"%s\" = ?;", tablename, idfield) // Quote identifiers
res, err := t.tx.QueryContext(ctx, query, key)
if err != nil {
t.err = err
return t
}
defer res.Close()
result, err := Rows2record(res)
if err != nil {
t.err = err
return t
}
for k := range output {
delete(output, k)
}
for k, v := range result {
output[k] = v
}
return t
}
func (t *Transaction) UpsertRecord(ctx context.Context, tablename string, idfield string, record Record, output Record) *Transaction {
if !isValidIdentifier(tablename) {
t.err = fmt.Errorf("Transaction.UpsertRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
return t
}
if !isValidIdentifier(idfield) {
t.err = fmt.Errorf("Transaction.UpsertRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
return t
}
if t.err != nil {
return t
}
result, err := upsert(ctx, t.tx, tablename, idfield, record)
if err != nil {
t.err = err
return t
}
for k := range output {
delete(output, k)
}
for k, v := range result {
output[k] = v
}
return t
}
func (t *Transaction) DeleteRecord(ctx context.Context, tablename string, idfield string, id any) *Transaction {
// Validation will be done by the internal deleteRecord helper
// if !isValidIdentifier(tablename) {
// t.err = fmt.Errorf("Transaction.DeleteRecord: %w: table name '%s'", ErrInvalidIdentifier, tablename)
// return t
// }
// if !isValidIdentifier(idfield) {
// t.err = fmt.Errorf("Transaction.DeleteRecord: %w: id field '%s'", ErrInvalidIdentifier, idfield)
// return t
// }
if t.err != nil {
return t
}
err := deleteRecord(ctx, t.tx, tablename, idfield, id) // t.tx satisfies iExecContext
if err != nil {
t.err = err
}
return t
}
// returns a value of the provided type, if the field exist and if it can be cast into the provided type parameter
func Value[T any](rec Record, field string) (value T, ok bool) {
var v any
// No validation for 'field' here as it's used to access a map key from an existing Record,
// not to construct SQL.
if v, ok = rec[field]; ok {
value, ok = v.(T)
}
return
}
// don't report an error if there are simply just 'no rows found'
func NoRowsOk(recs []Record, err error) ([]Record, error) {
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Return an empty, non-nil slice and nil error to indicate "no rows found, but that's okay".
// This makes it safer for callers to immediately use len() or range over the result.
return []Record{}, nil
}
return recs, err
}
return recs, nil
}
// isValidIdentifier checks if the given string is a safe identifier.
// Allows alphanumeric characters and underscores. Must not be empty.
func isValidIdentifier(identifier string) bool {
if len(identifier) == 0 {
return false
}
for _, r := range identifier {
if !((r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '_') {
return false
}
}
return true
}

5
crud/.show-it Normal file
View File

@ -0,0 +1,5 @@
clear
cp .user.db user.db
ls -l
echo "The CRUD Example"
---PAUSE---

35
demo/data-/.index.html Normal file
View File

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="pico.blue.css">
<title>Random Number Buttons</title>
<!-- <link rel="stylesheet" href="buttons.css"> -->
</head>
<body>
<div class="container">
<h1 style="margin-top: 2em;">Click for Random Numbers</h1>
<div class="grid">
<button onclick="setRandomNumber(event)">Click Me</button>
<button onclick="setRandomNumber(event)">Click Me</button>
<button onclick="setRandomNumber(event)">Click Me</button>
</div>
</div>
<script>
function setRandomNumber(e) {
const randomNum = Math.floor(Math.random() * 999) + 1;
const el = e.currentTarget;
el.textContent = randomNum;
el.dataset.count = (el.dataset.count | 0) + 1;
}
</script>
</body>
</html>

View File

@ -1,4 +1,5 @@
clear
cp .index.html index.html
ls -l
---PAUSE---
echo "live-server is running - press ctrl-c"

33
demo/data-/buttons.css Normal file
View File

@ -0,0 +1,33 @@
button {
transition: background-color 0.3s ease;
}
/* Red: 10+ clicks (2 or more digits) - put this FIRST */
button[data-count]:not([data-count="1"]):not([data-count="2"]):not([data-count="3"]):not([data-count="4"]):not([data-count="5"]):not([data-count="6"]):not([data-count="7"]):not([data-count="8"]):not([data-count="9"]) {
background-color: #ef4444;
border-color: #ef4444;
}
/* Green: 1-3 clicks - these override red due to higher specificity */
button[data-count="1"],
button[data-count="2"],
button[data-count="3"] {
background-color: #22c55e;
border-color: #22c55e;
}
/* Yellow: 4-6 clicks */
button[data-count="4"],
button[data-count="5"],
button[data-count="6"] {
background-color: #eab308;
border-color: #eab308;
}
/* Orange: 7-9 clicks */
button[data-count="7"],
button[data-count="8"],
button[data-count="9"] {
background-color: #f97316;
border-color: #f97316;
}

View File

@ -6,12 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="pico.blue.css">
<title>Random Number Buttons</title>
<link rel="stylesheet" href="buttons.css">
</head>
<body>
<div class="container">
<h1>Click for Random Numbers</h1>
<h1 style="margin-top: 2em;">Click for Random Numbers</h1>
<div class="grid">
<button onclick="setRandomNumber(event)">Click Me</button>
<button onclick="setRandomNumber(event)">Click Me</button>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,25 @@
<!DOCTYPE html >
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hypermedia</title>
<link rel="stylesheet" href="pico.blue.css">
<meta name="color-scheme" content="light dark">
<script type="module" src="datastar.js"></script>
</head>
<body class="container" data-theme="light">
<h1 style="margin-top: 1em; text-align:center">Hypermedia as the Engine of Application State</h1>
<main class="grid" data-theme="dark">
<article id="record1" data-init="@get('display-record1.html')"></article>
<article id="record2" data-init="@get('display-record2.html')"></article>
<article id="record3" data-init="@get('display-record3.html')"></article>
</main>
</body>
</html>

View File

@ -6,6 +6,12 @@
/**
* Styles
*/
@view-transition {
navigation: auto;
}
:root {
--pico-font-family-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--pico-font-family-sans-serif: system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif, var(--pico-font-family-emoji);

View File

@ -4,4 +4,4 @@ echo "patch signals with json data"
ls -l
---PAUSE---
echo "live-server is running - press ctrl-c"
live-server | micro index.html record1.json record3.json record3.json
live-server | micro index.html record1.json record2.json record3.json

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" defer
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.6/bundles/datastar.js"></script>
<title>Document</title>
<link rel="stylesheet" href="main.css">
</head>
<body>
<div data-signals:count="'START'" data-text="$count"></div>
<script>
let counter = 0;
// helper function that activates the PatchSignals
// watcher plugin to simulate a completed fetch request
const patchSignals = (count) => {
// a simulated `datastar-patch-signals` event
const detail = {
type: "datastar-patch-signals",
argsRaw: {
signals: JSON.stringify({ count })
}
};
// trigger the SSEEventWatcher by simulating a `datastar-fetch` event
document.dispatchEvent(new CustomEvent("datastar-fetch", { detail }));
};
setInterval(() => {
patchSignals(counter);
counter++;
}, 250);
</script>
</body>
</html>

View File

@ -0,0 +1,35 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
:root {
--base-color: oklch(92.2% 0 0);
--base-color-content: oklch(25.3267% 0.015896 252.417568);
}
@media (prefers-color-scheme: dark) {
:root {
--base-color: oklch(25.3267% 0.015896 252.417568);
--base-color-content: oklch(92.2% 0 0);
}
}
body {
display: grid;
place-content: center;
background-color: var(--base-color);
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
height: 100vh;
}
div {
color: var(--base-color-content);
font-size: 4rem;
}

View File

@ -0,0 +1,21 @@
let counter = 0;
// helper function that activates the PatchSignals
// watcher plugin to simulate a completed fetch request
const patchSignals = (count) => {
// a simulated `datastar-patch-signals` event
const detail = {
type: "datastar-patch-signals",
argsRaw: {
signals: JSON.stringify({ count })
}
};
// trigger the SSEEventWatcher by simulating a `datastar-fetch` event
document.dispatchEvent(new CustomEvent("datastar-fetch", { detail }));
};
setInterval(() => {
patchSignals(counter);
counter++;
}, 250);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Echo Example</title>
<link rel="stylesheet" href="pico.blue.css">
<meta name="color-scheme" content="light dark">
<script type="module" src="datastar.js"></script>
</head>
<body class="container" data-theme="light">
<h1 style="margin-top: 1em; text-align:center" data-signals:tadaa="[1,2,3,4,5,6,7,8]" data-signals="{myNumber: 1234, blubber: { tadaa: 'todo'}}" >The CRUD Example</h1>
<button data-on:click="console.log($tadaa[3],$blubber.tadaa); @patch('/echo')" >get the echo</button>
<main>
<div id="echo" ></div>
</main>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
module simple
go 1.25.0

150
experiments/simple/main.go Normal file
View File

@ -0,0 +1,150 @@
package main
import (
"html/template"
"io"
"log"
"net/http"
)
// RequestData holds all the information we want to display about a request
type RequestData struct {
Method string
URL string
Protocol string
Scheme string
Host string
Path string
RawQuery string
QueryParams map[string][]string
Headers map[string][]string
Body string
HasQuery bool
}
// collectRequestData extracts all relevant information from the HTTP request
func collectRequestData(r *http.Request) (*RequestData, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
defer r.Body.Close()
bodyString := string(body)
if len(bodyString) == 0 {
bodyString = "(empty)"
}
data := &RequestData{
Method: r.Method,
URL: r.URL.String(),
Protocol: r.Proto,
Scheme: r.URL.Scheme,
Host: r.Host,
Path: r.URL.Path,
RawQuery: r.URL.RawQuery,
QueryParams: r.URL.Query(),
Headers: r.Header,
Body: bodyString,
HasQuery: len(r.URL.Query()) > 0,
}
return data, nil
}
const echoTemplate = `
<div id="echo">
<div >
<h1>Request Echo</h1>
<div >
<h2>Request Line</h2>
<p><span class="label">Method:</span><span class="value">{{.Method}}</span></p>
<p><span class="label">URL:</span><span class="value">{{.URL}}</span></p>
<p><span class="label">Protocol:</span><span class="value">{{.Protocol}}</span></p>
</div>
<div >
<h2>URL Components</h2>
<p><span class="label">Scheme:</span><span class="value">{{.Scheme}}</span></p>
<p><span class="label">Host:</span><span class="value">{{.Host}}</span></p>
<p><span class="label">Path:</span><span class="value">{{.Path}}</span></p>
<p><span class="label">RawQuery:</span><span class="value">{{.RawQuery}}</span></p>
</div>
<div >
<h2>Query Parameters</h2>
{{if .HasQuery}}
{{range $key, $values := .QueryParams}}
{{range $values}}
<p><span class="label">{{$key}}:</span><span class="value">{{.}}</span></p>
{{end}}
{{end}}
{{else}}
<p>No query parameters</p>
{{end}}
</div>
<div >
<h2>Headers</h2>
<table>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
{{range $name, $values := .Headers}}
<tr>
<td>{{$name}}</td>
<td>{{range $i, $v := $values}}{{if $i}}, {{end}}{{$v}}{{end}}</td>
</tr>
{{end}}
</table>
</div>
<div >
<h2>Body</h2>
<pre>{{.Body}}</pre>
</div>
</div>
</div>
`
var tmpl *template.Template
func init() {
var err error
tmpl, err = template.New("echo").Parse(echoTemplate)
if err != nil {
log.Fatal("Error parsing template:", err)
}
}
func echoHandler(w http.ResponseWriter, r *http.Request) {
data, err := collectRequestData(r)
if err != nil {
http.Error(w, "Error reading request body", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err = tmpl.Execute(w, data)
if err != nil {
log.Printf("Error executing template: %v", err)
http.Error(w, "Error rendering response", http.StatusInternalServerError)
}
}
func main() {
http.HandleFunc("/echo", echoHandler)
fs := http.FileServer(http.Dir("./frontend"))
http.Handle("/", fs)
log.Println("Starting server on http://localhost:8080")
log.Println("Echo endpoint available at http://localhost:8080/echo")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}