Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server sent events #134

Merged
merged 12 commits into from
Dec 28, 2024
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.DS_Store
.build/
.index-build/
.vscode/
.index-build/
/Packages
Expand Down
63 changes: 63 additions & 0 deletions .swift-format
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{
"version" : 1,
"indentation" : {
"spaces" : 4
},
"tabWidth" : 4,
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"spacesAroundRangeFormationOperators" : false,
"indentConditionalCompilationBlocks" : false,
"indentSwitchCaseLabels" : false,
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : true,
"lineBreakBeforeEachGenericRequirement" : true,
"lineLength" : 150,
"maximumBlankLines" : 1,
"respectsExistingLineBreaks" : true,
"prioritizeKeepingFunctionOutputTogether" : true,
"multiElementCollectionTrailingCommas" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLiteralForEmptyCollectionInit" : false,
"AlwaysUseLowerCamelCase" : false,
"AmbiguousTrailingClosureOverload" : true,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : true,
"FileScopedDeclarationPrivacy" : true,
"FullyIndirectEnum" : true,
"GroupNumericLiterals" : true,
"IdentifiersMustBeASCII" : true,
"NeverForceUnwrap" : false,
"NeverUseForceTry" : false,
"NeverUseImplicitlyUnwrappedOptionals" : false,
"NoAccessLevelOnExtensionDeclaration" : true,
"NoAssignmentInExpressions" : true,
"NoBlockComments" : true,
"NoCasesWithOnlyFallthrough" : true,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : true,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : true,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : true,
"ReturnVoidInsteadOfEmptyTuple" : true,
"UseEarlyExits" : false,
"UseExplicitNilCheckInConditions" : false,
"UseLetInEveryBoundCaseVariable" : false,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : false,
"UseSynthesizedInitializer" : false,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : false
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Examples converted to Hummingbird 2.0
- [proxy-server](https://github.com/hummingbird-project/hummingbird-examples/tree/main/proxy-server) - Using AsyncHTTPClient to build a proxy server
- [response-body-processing](https://github.com/hummingbird-project/hummingbird-examples/tree/main/response-body-processing) - Example showing how to process a response body in middleware.
- [s3-file-provider](https://github.com/hummingbird-project/hummingbird-examples/tree/main/s3-file-provider) - Use a custom FileProvider to serve files from S3 with FileMiddleware.
- [server-send-events](https://github.com/hummingbird-project/hummingbird-examples/tree/main/server-send-events) - Server sent events.
- [sessions](https://github.com/hummingbird-project/hummingbird-examples/tree/main/sessions) - Username/password and session authentication.
- [todos-dynamodb](https://github.com/hummingbird-project/hummingbird-examples/tree/main/todos-dynamodb) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using DynamoDB.
- [todos-fluent](https://github.com/hummingbird-project/hummingbird-examples/tree/1.x.x/todos-fluent) - Todos application, based off [TodoBackend](http://todobackend.com) spec, using Fluent
Expand Down
1 change: 0 additions & 1 deletion auth-cognito/Packages/hummingbird
Submodule hummingbird deleted from 195aee
2 changes: 2 additions & 0 deletions server-sent-events/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.build
.git
9 changes: 9 additions & 0 deletions server-sent-events/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
/.build
/.swiftpm
/.devContainer
/Packages
/*.xcodeproj
xcuserdata/
/.vscode/*
!/.vscode/hummingbird.code-snippets
86 changes: 86 additions & 0 deletions server-sent-events/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# ================================
# Build image
# ================================
FROM swift:6.0-jammy as build

# Install OS updates
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get install -y libjemalloc-dev \
&& rm -rf /var/lib/apt/lists/*

# Set up a build area
WORKDIR /build

# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve

# Copy entire repo into container
COPY . .

# Build everything, with optimizations, with static linking, and using jemalloc
RUN swift build -c release \
--static-swift-stdlib \
-Xlinker -ljemalloc

# Switch to the staging area
WORKDIR /staging

# Copy main executable to staging area
RUN cp "$(swift build --package-path /build -c release --show-bin-path)/App" ./

# Copy static swift backtracer binary to staging area
RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./

# Copy resources bundled by SPM to staging area
RUN find -L "$(swift build --package-path /build -c release --show-bin-path)/" -regex '.*\.resources$' -exec cp -Ra {} ./ \;

# Copy any resouces from the public directory and views directory if the directories exist
# Ensure that by default, neither the directory nor any of its contents are writable.
RUN [ -d /build/public ] && { mv /build/public ./public && chmod -R a-w ./public; } || true

# ================================
# Run image
# ================================
FROM ubuntu:jammy

# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
# libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
&& rm -r /var/lib/apt/lists/*

# Create a hummingbird user and group with /app as its home directory
RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app hummingbird

# Switch to the new home directory
WORKDIR /app

# Copy built executable and any staged resources from builder
COPY --from=build --chown=hummingbird:hummingbird /staging /app

# Provide configuration needed by the built-in crash reporter and some sensible default behaviors.
ENV SWIFT_BACKTRACE=enable=yes,sanitize=yes,threads=all,images=all,interactive=no,swift-backtrace=./swift-backtrace-static

# Ensure all further commands run as the hummingbird user
USER hummingbird:hummingbird

# Let Docker bind to port 8080
EXPOSE 8080

# Start the Hummingbird service when the image is run, default to listening on 8080 in production environment
ENTRYPOINT ["./App"]
CMD ["--hostname", "0.0.0.0", "--port", "8080"]
36 changes: 36 additions & 0 deletions server-sent-events/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// swift-tools-version:6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "server_sent_events",
platforms: [.macOS(.v15), .iOS(.v18), .tvOS(.v18)],
products: [
.executable(name: "App", targets: ["App"])
],
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.6.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/orlandos-nl/SSEKit.git", from: "1.1.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "SSEKit", package: "SSEKit"),
],
path: "Sources/App"
),
.testTarget(
name: "AppTests",
dependencies: [
.byName(name: "App"),
.product(name: "HummingbirdTesting", package: "hummingbird"),
],
path: "Tests/AppTests"
),
]
)
6 changes: 6 additions & 0 deletions server-sent-events/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Server Send Events

Example demonstrating how to setup a route returning Server Sent Events.

The application has one route. `GET /events`. If you call this then every other request sent to the server will be reported back to this route as a server sent event.

27 changes: 27 additions & 0 deletions server-sent-events/Sources/App/App.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import ArgumentParser
import Hummingbird
import Logging

@main
struct App: AsyncParsableCommand, AppArguments {
@Option(name: .shortAndLong)
var hostname: String = "127.0.0.1"

@Option(name: .shortAndLong)
var port: Int = 8080

@Option(name: .shortAndLong)
var logLevel: Logger.Level?

func run() async throws {
let app = try await buildApplication(self)
try await app.runService()
}
}

/// Extend `Logger.Level` so it can be used as an argument
#if hasFeature(RetroactiveAttribute)
extension Logger.Level: @retroactive ExpressibleByArgument {}
#else
extension Logger.Level: ExpressibleByArgument {}
#endif
104 changes: 104 additions & 0 deletions server-sent-events/Sources/App/Application+build.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Hummingbird
import Logging
import NIOCore
import SSEKit
import ServiceLifecycle

/// Application arguments protocol. We use a protocol so we can call
/// `buildApplication` inside Tests as well as in the App executable.
/// Any variables added here also have to be added to `App` in App.swift and
/// `TestArguments` in AppTest.swift
public protocol AppArguments {
var hostname: String { get }
var port: Int { get }
var logLevel: Logger.Level? { get }
}

// Request context used by application
struct AppRequestContext: RequestContext {
var coreContext: CoreRequestContextStorage
let channel: Channel

init(source: Source) {
self.coreContext = .init(source: source)
self.channel = source.channel
}
}

/// Build application
/// - Parameter arguments: application arguments
public func buildApplication(
_ arguments: some AppArguments
) async throws
-> some ApplicationProtocol
{
let environment = Environment()
let logger = {
var logger = Logger(label: "server_side_events")
logger.logLevel =
arguments.logLevel ?? environment.get("LOG_LEVEL").map {
Logger.Level(rawValue: $0) ?? .info
} ?? .info
return logger
}()
let requestPublisher = Publisher<String>()
let router = buildRouter(requestPublisher: requestPublisher)
let app = Application(
router: router,
configuration: .init(
address: .hostname(arguments.hostname, port: arguments.port),
serverName: "server_sent_events"
),
services: [requestPublisher],
logger: logger
)
return app
}

/// Build router
func buildRouter(requestPublisher: Publisher<String>) -> Router<AppRequestContext> {
let router = Router(context: AppRequestContext.self)
// Add middleware
router.addMiddleware {
// logging middleware
LogRequestsMiddleware(.info)
PublishRequestsMiddleware(requestPublisher: requestPublisher)
}
router.get("events") { request, context -> Response in
.init(
status: .ok,
headers: [.contentType: "text/event-stream"],
body: .init { writer in
let allocator = ByteBufferAllocator()
let (stream, id) = requestPublisher.subscribe()
try await withGracefulShutdownHandler {
// If connection if closed then this function will call the `onInboundCLosed` closure
try await request.body.consumeWithInboundCloseHandler { requestBody in
for try await value in stream {
try await writer.write(
ServerSentEvent(data: .init(string: value)).makeBuffer(
allocator: allocator
)
)
}
} onInboundClosed: {
requestPublisher.unsubscribe(id)
}
} onGracefulShutdown: {
requestPublisher.unsubscribe(id)
}
try await writer.finish(nil)
}
)
}
return router
}

/// Middleware to publish requests
struct PublishRequestsMiddleware<Context: RequestContext>: RouterMiddleware {
let requestPublisher: Publisher<String>
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
await requestPublisher.publish("\(request.method): \(request.uri.path)")
return try await next(request, context)
}
}
Loading
Loading