some experiments
This commit is contained in:
parent
07b54b15ae
commit
4120caf1b4
5
crud/.server/Makefile
Normal file
5
crud/.server/Makefile
Normal file
@ -0,0 +1,5 @@
|
||||
run:
|
||||
go run .
|
||||
|
||||
build:
|
||||
go build -tags prod .
|
||||
98
crud/.server/api/echo.go
Normal file
98
crud/.server/api/echo.go
Normal 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
BIN
crud/.server/crud
Executable file
Binary file not shown.
115
crud/.server/datastar/consts.go
Normal file
115
crud/.server/datastar/consts.go
Normal 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
|
||||
234
crud/.server/datastar/execute-script-sugar.go
Normal file
234
crud/.server/datastar/execute-script-sugar.go
Normal 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"),
|
||||
)
|
||||
}
|
||||
112
crud/.server/datastar/execute.go
Normal file
112
crud/.server/datastar/execute.go
Normal 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...,
|
||||
)
|
||||
}
|
||||
190
crud/.server/datastar/fragments-sugar.go
Normal file
190
crud/.server/datastar/fragments-sugar.go
Normal 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...))
|
||||
}
|
||||
190
crud/.server/datastar/fragments.go
Normal file
190
crud/.server/datastar/fragments.go
Normal 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
|
||||
}
|
||||
20
crud/.server/datastar/fragments_test.go
Normal file
20
crud/.server/datastar/fragments_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
42
crud/.server/datastar/signals-sugar.go
Normal file
42
crud/.server/datastar/signals-sugar.go
Normal 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
|
||||
}
|
||||
147
crud/.server/datastar/signals.go
Normal file
147
crud/.server/datastar/signals.go
Normal 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
|
||||
}
|
||||
294
crud/.server/datastar/sse-compression.go
Normal file
294
crud/.server/datastar/sse-compression.go
Normal 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
|
||||
}
|
||||
212
crud/.server/datastar/sse.go
Normal file
212
crud/.server/datastar/sse.go
Normal 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
|
||||
}
|
||||
15
crud/.server/datastar/types.go
Normal file
15
crud/.server/datastar/types.go
Normal 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
|
||||
}
|
||||
23
crud/.server/frontend/index.html
Normal file
23
crud/.server/frontend/index.html
Normal 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
4
crud/.server/getuser.sql
Normal 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
24
crud/.server/go.mod
Normal 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
82
crud/.server/go.sum
Normal 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
251
crud/.server/main_dev.go
Normal 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
252
crud/.server/main_prod.go
Normal 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
25
crud/.server/routes.go
Normal 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))
|
||||
}
|
||||
614
crud/.server/sqlite/database.go
Normal file
614
crud/.server/sqlite/database.go
Normal 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
|
||||
}
|
||||
0
crud/.server/templates/board-content.html
Normal file
0
crud/.server/templates/board-content.html
Normal file
5
crud/.show-it
Normal file
5
crud/.show-it
Normal 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
35
demo/data-/.index.html
Normal 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>
|
||||
@ -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
33
demo/data-/buttons.css
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
9
demo/datasstar-static/html-snippets/datastar.js
Normal file
9
demo/datasstar-static/html-snippets/datastar.js
Normal file
File diff suppressed because one or more lines are too long
7
demo/datasstar-static/html-snippets/datastar.js.map
Normal file
7
demo/datasstar-static/html-snippets/datastar.js.map
Normal file
File diff suppressed because one or more lines are too long
25
demo/datasstar-static/html-snippets/index.html
Normal file
25
demo/datasstar-static/html-snippets/index.html
Normal 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>
|
||||
@ -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);
|
||||
@ -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
|
||||
9
demo/datasstar-static/signals/datastar.js
Normal file
9
demo/datasstar-static/signals/datastar.js
Normal file
File diff suppressed because one or more lines are too long
7
demo/datasstar-static/signals/datastar.js.map
Normal file
7
demo/datasstar-static/signals/datastar.js.map
Normal file
File diff suppressed because one or more lines are too long
2808
demo/datasstar-static/signals/pico.blue.css
Normal file
2808
demo/datasstar-static/signals/pico.blue.css
Normal file
File diff suppressed because it is too large
Load Diff
42
experiments/patchsignals/index.html
Normal file
42
experiments/patchsignals/index.html
Normal 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>
|
||||
35
experiments/patchsignals/main.css
Normal file
35
experiments/patchsignals/main.css
Normal 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;
|
||||
}
|
||||
21
experiments/patchsignals/main.js
Normal file
21
experiments/patchsignals/main.js
Normal 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);
|
||||
9
experiments/simple/frontend/datastar.js
Normal file
9
experiments/simple/frontend/datastar.js
Normal file
File diff suppressed because one or more lines are too long
7
experiments/simple/frontend/datastar.js.map
Normal file
7
experiments/simple/frontend/datastar.js.map
Normal file
File diff suppressed because one or more lines are too long
24
experiments/simple/frontend/index.html
Normal file
24
experiments/simple/frontend/index.html
Normal 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>
|
||||
2808
experiments/simple/frontend/pico.blue.css
Normal file
2808
experiments/simple/frontend/pico.blue.css
Normal file
File diff suppressed because it is too large
Load Diff
3
experiments/simple/go.mod
Normal file
3
experiments/simple/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module simple
|
||||
|
||||
go 1.25.0
|
||||
150
experiments/simple/main.go
Normal file
150
experiments/simple/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user