Skip to content

Commit

Permalink
add module for HTMX SSE extension (#5)
Browse files Browse the repository at this point in the history
* wip commit [vapor example works|hummingbird example does not work]

* removed .devontainer and .vscode and added do .gitignore

* changed API from .hx.sse.ext() to .hx.ext(.sse)

* HummingbirdSSEDemo is working [AsyncStream task is not cancelled on ctrl+c]

* fixed no graceful shutdown

* implemented Hummingbird Response.stream()

* moved .cancelOnGracefulShutdown() to Response.stream()

* removed duplicate Examples and defined Response.stream() for HTML protocol

* moved definition of sse extension into ElementaryHxSSE

* renamed library from 'ElementaryHxSSE' to 'ElementaryHTMXSSE'

* removed unstructured Task in Vapor Example

* reworked sse-trigger and implemented tests

* removed dead code and renamed 'ElementaryHTMXSSE+Hummingbird' to 'Response+Stream'
  • Loading branch information
h0p3zZ committed Jul 16, 2024
1 parent 36dcbc9 commit b9f601e
Show file tree
Hide file tree
Showing 19 changed files with 5,771 additions and 6 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
/.vscode
/.devcontainer
5 changes: 5 additions & 0 deletions Examples/HummingbirdDemo/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ let package = Package(
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0-rc.1"),
.package(url: "https://github.com/hummingbird-community/hummingbird-elementary.git", from: "0.1.0-rc.1"),
.package(path: "../../"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"),
.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"),
],
targets: [
.executableTarget(
Expand All @@ -21,6 +23,9 @@ let package = Package(
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "HummingbirdElementary", package: "hummingbird-elementary"),
.product(name: "ElementaryHTMX", package: "elementary-htmx"),
.product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
],
resources: [
.copy("Public"),
Expand Down
2,684 changes: 2,683 additions & 1 deletion Examples/HummingbirdDemo/Sources/App/Public/htmx.min.js

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions Examples/HummingbirdDemo/Sources/App/Public/htmxsse.min.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
!(function () {
var e;
function t(e) {
return new EventSource(e, { withCredentials: !0 });
}
function n(t) {
if (!e.bodyContains(t)) {
var n = e.getInternalData(t).sseEventSource;
if (null != n) return n.close(), !0;
}
return !1;
}
function r(t, n) {
var r = [];
return (
e.hasAttribute(t, n) && r.push(t),
t.querySelectorAll('[' + n + '], [data-' + n + ']').forEach(function (e) {
r.push(e);
}),
r
);
}
function s(t, n) {
e.withExtensions(t, function (e) {
n = e.transformResponse(n, null, t);
});
var r = e.getSwapSpecification(t),
s = e.getTarget(t);
e.swap(s, n, r);
}
function a(t) {
return null != e.getInternalData(t).sseEventSource;
}
htmx.defineExtension('sse', {
init: function (n) {
(e = n), null == htmx.createEventSource && (htmx.createEventSource = t);
},
onEvent: function (t, o) {
var i = o.target || o.detail.elt;
switch (t) {
case 'htmx:beforeCleanupElement':
var u = e.getInternalData(i);
return void (u.sseEventSource && u.sseEventSource.close());
case 'htmx:afterProcessNode':
!(function t(o, i) {
var u;
if (null == o) return null;
r(o, 'sse-connect').forEach(function (r) {
var s,
a,
o,
u,
c = e.getAttributeValue(r, 'sse-connect');
null != c &&
((s = r),
(a = c),
(o = i),
((u = htmx.createEventSource(a)).onerror = function (r) {
if (
(e.triggerErrorEvent(s, 'htmx:sseError', {
error: r,
source: u,
}),
!n(s) && u.readyState === EventSource.CLOSED)
) {
var a = Math.random() * (2 ^ (o = o || 0)) * 500;
window.setTimeout(function () {
t(s, Math.min(7, o + 1));
}, a);
}
}),
(u.onopen = function (t) {
e.triggerEvent(s, 'htmx:sseOpen', { source: u });
}),
(e.getInternalData(s).sseEventSource = u));
}),
r((u = o), 'sse-swap').forEach(function (t) {
var r = e.getClosestMatch(t, a);
if (null == r) return null;
for (
var o = e.getInternalData(r).sseEventSource,
i = e.getAttributeValue(t, 'sse-swap').split(','),
c = 0;
c < i.length;
c++
) {
var l = i[c].trim(),
v = function (a) {
if (!n(r)) {
if (!e.bodyContains(t))
return void o.removeEventListener(l, v);
e.triggerEvent(u, 'htmx:sseBeforeMessage', a) &&
(s(t, a.data),
e.triggerEvent(u, 'htmx:sseMessage', a));
}
};
(e.getInternalData(t).sseEventListener = v),
o.addEventListener(l, v);
}
}),
r(u, 'hx-trigger').forEach(function (t) {
var r = e.getClosestMatch(t, a);
if (null == r) return null;
var s = e.getInternalData(r).sseEventSource,
o = e.getAttributeValue(t, 'hx-trigger');
if (null != o && 'sse:' == o.slice(0, 4)) {
var i = function (a) {
!n(r) &&
(e.bodyContains(t) || s.removeEventListener(o, i),
htmx.trigger(t, o, a),
htmx.trigger(t, 'htmx:sseMessage', a));
};
(e.getInternalData(u).sseEventListener = i),
s.addEventListener(o.slice(4), i);
}
});
})(i);
}
},
});
})();
17 changes: 17 additions & 0 deletions Examples/HummingbirdDemo/Sources/App/Response+Stream.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Elementary
import Hummingbird
import HummingbirdElementary
import ServiceLifecycle

public extension Response {
static func stream<BufferSequence: AsyncSequence & Sendable>(_ asyncSequence: BufferSequence) -> Response where BufferSequence.Element == ByteBuffer {
.init(status: .ok, headers: [.contentType: "text/event-stream"], body: .init(asyncSequence: asyncSequence.cancelOnGracefulShutdown()))
}

static func stream<BufferSequence: AsyncSequence & Sendable>(_ asyncSequence: BufferSequence, eventName: String? = nil) -> Response where BufferSequence.Element: HTML {
let eventStream = asyncSequence.map { html in
ByteBuffer(bytes: "\(eventName != nil ? "event:\(eventName!)\n" : "")data:\(html.render())\n\n".utf8)
}
return .stream(eventStream)
}
}
9 changes: 9 additions & 0 deletions Examples/HummingbirdDemo/Sources/App/Routes.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AsyncAlgorithms
import Hummingbird
import HummingbirdElementary

Expand All @@ -10,6 +11,14 @@ func addRoutes(to router: Router<some RequestContext>) {
}
}

router.get("/time") { _, _ in
let timerSequence = AsyncTimerSequence(interval: .seconds(1), clock: ContinuousClock())
.map { _ in
TimeHeading()
}
return Response.stream(timerSequence, eventName: "time")
}

router.post("/items") { request, context in
let body = try await request.decode(as: AddItemRequest.self, context: context)
await Database.shared.addItem(body.item)
Expand Down
15 changes: 14 additions & 1 deletion Examples/HummingbirdDemo/Sources/App/Views.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Elementary
import ElementaryHTMX
import ElementaryHTMXSSE
import Foundation

struct MainPage: HTMLDocument {
var title: String { "Hummingbird + Elementary + HTMX" }
Expand All @@ -9,12 +10,16 @@ struct MainPage: HTMLDocument {
var head: some HTML {
meta(.charset(.utf8))
script(.src("/htmx.min.js")) {}
script(.src("/htmxsse.min.js")) {}
link(.href("/pico.min.css"), .rel(.stylesheet))
}

var body: some HTML {
header(.class("container")) {
h2 { "Hummingbird + Elementary + HTMX Demo" }
div(.hx.ext(.sse), .sse.connect("/time"), .sse.swap("time")) {
TimeHeading()
}
}
main(.class("container")) {
div {
Expand Down Expand Up @@ -50,3 +55,11 @@ struct ItemList: HTML {
}
}
}

struct TimeHeading: HTML {
var content: some HTML<HTMLTag.h4> {
h4 {
"Server Time: \(Date())"
}
}
}
3 changes: 3 additions & 0 deletions Examples/VaporDemo/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ let package = Package(
.package(url: "https://github.com/vapor/vapor", from: "4.102.0"),
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
.package(path: "../../"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"),
],
targets: [
.executableTarget(
Expand All @@ -21,6 +22,8 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
.product(name: "VaporElementary", package: "vapor-elementary"),
.product(name: "ElementaryHTMX", package: "elementary-htmx"),
.product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
],
resources: [
.copy("Public"),
Expand Down
2,684 changes: 2,683 additions & 1 deletion Examples/VaporDemo/Sources/App/Public/htmx.min.js

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions Examples/VaporDemo/Sources/App/Public/htmxsse.min.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
!(function () {
var e;
function t(e) {
return new EventSource(e, { withCredentials: !0 });
}
function n(t) {
if (!e.bodyContains(t)) {
var n = e.getInternalData(t).sseEventSource;
if (null != n) return n.close(), !0;
}
return !1;
}
function r(t, n) {
var r = [];
return (
e.hasAttribute(t, n) && r.push(t),
t.querySelectorAll('[' + n + '], [data-' + n + ']').forEach(function (e) {
r.push(e);
}),
r
);
}
function s(t, n) {
e.withExtensions(t, function (e) {
n = e.transformResponse(n, null, t);
});
var r = e.getSwapSpecification(t),
s = e.getTarget(t);
e.swap(s, n, r);
}
function a(t) {
return null != e.getInternalData(t).sseEventSource;
}
htmx.defineExtension('sse', {
init: function (n) {
(e = n), null == htmx.createEventSource && (htmx.createEventSource = t);
},
onEvent: function (t, o) {
var i = o.target || o.detail.elt;
switch (t) {
case 'htmx:beforeCleanupElement':
var u = e.getInternalData(i);
return void (u.sseEventSource && u.sseEventSource.close());
case 'htmx:afterProcessNode':
!(function t(o, i) {
var u;
if (null == o) return null;
r(o, 'sse-connect').forEach(function (r) {
var s,
a,
o,
u,
c = e.getAttributeValue(r, 'sse-connect');
null != c &&
((s = r),
(a = c),
(o = i),
((u = htmx.createEventSource(a)).onerror = function (r) {
if (
(e.triggerErrorEvent(s, 'htmx:sseError', {
error: r,
source: u,
}),
!n(s) && u.readyState === EventSource.CLOSED)
) {
var a = Math.random() * (2 ^ (o = o || 0)) * 500;
window.setTimeout(function () {
t(s, Math.min(7, o + 1));
}, a);
}
}),
(u.onopen = function (t) {
e.triggerEvent(s, 'htmx:sseOpen', { source: u });
}),
(e.getInternalData(s).sseEventSource = u));
}),
r((u = o), 'sse-swap').forEach(function (t) {
var r = e.getClosestMatch(t, a);
if (null == r) return null;
for (
var o = e.getInternalData(r).sseEventSource,
i = e.getAttributeValue(t, 'sse-swap').split(','),
c = 0;
c < i.length;
c++
) {
var l = i[c].trim(),
v = function (a) {
if (!n(r)) {
if (!e.bodyContains(t))
return void o.removeEventListener(l, v);
e.triggerEvent(u, 'htmx:sseBeforeMessage', a) &&
(s(t, a.data),
e.triggerEvent(u, 'htmx:sseMessage', a));
}
};
(e.getInternalData(t).sseEventListener = v),
o.addEventListener(l, v);
}
}),
r(u, 'hx-trigger').forEach(function (t) {
var r = e.getClosestMatch(t, a);
if (null == r) return null;
var s = e.getInternalData(r).sseEventSource,
o = e.getAttributeValue(t, 'hx-trigger');
if (null != o && 'sse:' == o.slice(0, 4)) {
var i = function (a) {
!n(r) &&
(e.bodyContains(t) || s.removeEventListener(o, i),
htmx.trigger(t, o, a),
htmx.trigger(t, 'htmx:sseMessage', a));
};
(e.getInternalData(u).sseEventListener = i),
s.addEventListener(o.slice(4), i);
}
});
})(i);
}
},
});
})();
12 changes: 12 additions & 0 deletions Examples/VaporDemo/Sources/App/Routes.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AsyncAlgorithms
import Vapor
import VaporElementary

Expand All @@ -15,4 +16,15 @@ func addRoutes(to app: Application) {
ResultView(x: x, y: y)
}
}

app.get("time") { request -> Response in
let body = Response.Body(stream: { writer in
request.eventLoop.scheduleRepeatedTask(initialDelay: .seconds(1), delay: .seconds(1)) { _ in
try writer.write(.buffer(.init(string: "event: time\ndata: Server Time: \(Date())\n\n")), promise: nil)
}
})
let res = Response(status: .ok, body: body)
res.headers.replaceOrAdd(name: "Content-Type", value: "text/event-stream")
return res
}
}
Loading

0 comments on commit b9f601e

Please sign in to comment.