1
1
use std:: sync:: Arc ;
2
2
3
+ use crate :: cache:: ResolveResult ;
4
+ use futures:: StreamExt ;
3
5
use opentelemetry:: global;
4
6
use poem:: middleware:: OpenTelemetryMetrics ;
5
7
use poem:: web:: Data ;
6
- use poem:: { Body , EndpointExt , IntoResponse , Request , Response } ;
7
8
use poem:: { get, handler, listener:: TcpListener , middleware:: Cors , Route , Server } ;
9
+ use poem:: { Body , EndpointExt , IntoResponse , Request , Response } ;
8
10
use reqwest:: StatusCode ;
9
11
use tracing:: info;
10
- use futures:: StreamExt ;
11
- use bytes:: Bytes ;
12
- use crate :: cache:: ResolveResult ;
13
12
14
13
use crate :: middlewares:: tracing:: TraceId ;
15
- use crate :: models:: deployment:: { Deployment , DeploymentFile , DeploymentFileEntry } ;
14
+ use crate :: models:: deployment:: { Deployment , DeploymentFile } ;
16
15
use crate :: models:: domain:: Domain ;
17
16
use crate :: models:: site:: Site ;
18
17
use crate :: routes:: error:: HttpError ;
@@ -40,7 +39,8 @@ pub async fn serve(state: State) {
40
39
async fn resolve_http ( request : & Request , state : Data < & State > ) -> impl IntoResponse {
41
40
// extract host and path
42
41
let headers = request. headers ( ) ;
43
- let host = headers. get ( "host" )
42
+ let host = headers
43
+ . get ( "host" )
44
44
. and_then ( |h| h. to_str ( ) . ok ( ) )
45
45
. unwrap_or ( "localhost" ) ;
46
46
let raw_path = request. uri ( ) . path ( ) ;
@@ -60,10 +60,22 @@ async fn resolve_http(request: &Request, state: Data<&State>) -> impl IntoRespon
60
60
. get_with ( cache_key. clone ( ) , async move {
61
61
match get_last_deployment ( & host_for_cache, & state_for_cache. clone ( ) ) . await {
62
62
Ok ( deployment) => {
63
- let lookup_path = if path_for_cache. is_empty ( ) { "index.html" } else { path_for_cache. as_str ( ) } ;
64
- match DeploymentFile :: get_file_by_path ( & state_for_cache. clone ( ) . database , & deployment. deployment_id , lookup_path) . await {
63
+ let lookup_path = if path_for_cache. is_empty ( ) {
64
+ "index.html"
65
+ } else {
66
+ path_for_cache. as_str ( )
67
+ } ;
68
+ match DeploymentFile :: get_file_by_path (
69
+ & state_for_cache. clone ( ) . database ,
70
+ & deployment. deployment_id ,
71
+ lookup_path,
72
+ )
73
+ . await
74
+ {
65
75
Ok ( entry) => ResolveResult :: Success ( entry) ,
66
- Err ( _) => ResolveResult :: NotFound ( format ! ( "File not found: {}" , lookup_path) ) ,
76
+ Err ( _) => {
77
+ ResolveResult :: NotFound ( format ! ( "File not found: {}" , lookup_path) )
78
+ }
67
79
}
68
80
}
69
81
Err ( err) => {
@@ -81,10 +93,19 @@ async fn resolve_http(request: &Request, state: Data<&State>) -> impl IntoRespon
81
93
// handle the resolved result
82
94
match cached_resolve {
83
95
ResolveResult :: Success ( deployment_file) => {
84
- info ! ( "Serving file: {} (cached lookup)" , deployment_file. file_hash) ;
96
+ info ! (
97
+ "Serving file: {} (cached lookup)" ,
98
+ deployment_file. file_hash
99
+ ) ;
85
100
// if eligible for full caching
86
101
if ( deployment_file. deployment_file_mime_type == "text/html"
87
- || HTML_CACHE_FILE_EXTENSIONS . contains ( & deployment_file. deployment_file_file_path . split ( '.' ) . last ( ) . unwrap_or ( "" ) ) )
102
+ || HTML_CACHE_FILE_EXTENSIONS . contains (
103
+ & deployment_file
104
+ . deployment_file_file_path
105
+ . split ( '.' )
106
+ . last ( )
107
+ . unwrap_or ( "" ) ,
108
+ ) )
88
109
&& deployment_file. file_size . unwrap_or ( 0 ) <= HTML_CACHE_SIZE_LIMIT as i64
89
110
{
90
111
let file_key = deployment_file. file_hash . clone ( ) ;
@@ -93,18 +114,27 @@ async fn resolve_http(request: &Request, state: Data<&State>) -> impl IntoRespon
93
114
let body = Body :: from_bytes ( cached_bytes. clone ( ) ) ;
94
115
return Response :: builder ( )
95
116
. status ( StatusCode :: OK )
96
- . header ( "content-type" , deployment_file. deployment_file_mime_type . clone ( ) )
117
+ . header (
118
+ "content-type" ,
119
+ deployment_file. deployment_file_mime_type . clone ( ) ,
120
+ )
97
121
. body ( body) ;
98
122
}
99
123
// fetch from S3 and cache
100
124
match state. storage . bucket . get_object ( & file_key) . await {
101
125
Ok ( data) => {
102
126
let bytes = data. into_bytes ( ) ;
103
- state. cache . file_bytes . insert ( file_key. clone ( ) , bytes. clone ( ) ) ;
127
+ state
128
+ . cache
129
+ . file_bytes
130
+ . insert ( file_key. clone ( ) , bytes. clone ( ) ) ;
104
131
let body = Body :: from_bytes ( bytes) ;
105
132
return Response :: builder ( )
106
133
. status ( StatusCode :: OK )
107
- . header ( "content-type" , deployment_file. deployment_file_mime_type . clone ( ) )
134
+ . header (
135
+ "content-type" ,
136
+ deployment_file. deployment_file_mime_type . clone ( ) ,
137
+ )
108
138
. body ( body) ;
109
139
}
110
140
Err ( e) => {
@@ -128,7 +158,10 @@ async fn resolve_http(request: &Request, state: Data<&State>) -> impl IntoRespon
128
158
let body = Body :: from_bytes_stream ( stream) ;
129
159
return Response :: builder ( )
130
160
. status ( StatusCode :: OK )
131
- . header ( "content-type" , deployment_file. deployment_file_mime_type . clone ( ) )
161
+ . header (
162
+ "content-type" ,
163
+ deployment_file. deployment_file_mime_type . clone ( ) ,
164
+ )
132
165
. body ( body) ;
133
166
}
134
167
Err ( e) => {
@@ -140,10 +173,112 @@ async fn resolve_http(request: &Request, state: Data<&State>) -> impl IntoRespon
140
173
}
141
174
}
142
175
ResolveResult :: NotFound ( reason) => {
143
- info ! ( "Not found: {}" , reason) ;
176
+ // SPA fallback when the specific file isn't found
177
+ info ! ( "Not found: {}, attempting SPA fallback" , reason) ;
178
+ // Try index.html for SPA routing
179
+ let spa_cache_key = format ! ( "resolve:{}:index.html" , host) ;
180
+ let state_for_spa = state. clone ( ) ;
181
+ let host_for_spa = host. to_string ( ) ;
182
+ let spa_result: ResolveResult = state
183
+ . cache
184
+ . resolve
185
+ . get_with ( spa_cache_key. clone ( ) , async move {
186
+ match get_last_deployment ( & host_for_spa, & state_for_spa. clone ( ) ) . await {
187
+ Ok ( deployment) => {
188
+ match DeploymentFile :: get_file_by_path (
189
+ & state_for_spa. clone ( ) . database ,
190
+ & deployment. deployment_id ,
191
+ "index.html" ,
192
+ )
193
+ . await
194
+ {
195
+ Ok ( entry) => ResolveResult :: Success ( entry) ,
196
+ Err ( _) => {
197
+ ResolveResult :: NotFound ( "SPA index.html not found" . to_string ( ) )
198
+ }
199
+ }
200
+ }
201
+ Err ( err) => {
202
+ let msg = err. to_string ( ) ;
203
+ if let HttpError :: NotFound = err {
204
+ ResolveResult :: NotFound ( msg)
205
+ } else {
206
+ ResolveResult :: Error ( msg)
207
+ }
208
+ }
209
+ }
210
+ } )
211
+ . await ;
212
+ // If SPA index.html found, serve it using same caching logic
213
+ if let ResolveResult :: Success ( deployment_file) = spa_result {
214
+ info ! ( "Serving SPA index.html for {} {}" , host, path) ;
215
+ // full in-memory cache eligible?
216
+ if ( deployment_file. deployment_file_mime_type == "text/html"
217
+ || HTML_CACHE_FILE_EXTENSIONS . contains (
218
+ & deployment_file
219
+ . deployment_file_file_path
220
+ . split ( '.' )
221
+ . last ( )
222
+ . unwrap_or ( "" ) ,
223
+ ) )
224
+ && deployment_file. file_size . unwrap_or ( 0 ) <= HTML_CACHE_SIZE_LIMIT as i64
225
+ {
226
+ let file_key = deployment_file. file_hash . clone ( ) ;
227
+ // check in-memory cache
228
+ if let Some ( cached_bytes) = state. cache . file_bytes . get ( & file_key) . await {
229
+ let body = Body :: from_bytes ( cached_bytes. clone ( ) ) ;
230
+ return Response :: builder ( )
231
+ . status ( StatusCode :: OK )
232
+ . header (
233
+ "content-type" ,
234
+ deployment_file. deployment_file_mime_type . clone ( ) ,
235
+ )
236
+ . body ( body) ;
237
+ }
238
+ // fetch and cache
239
+ if let Ok ( data) = state. storage . bucket . get_object ( & file_key) . await {
240
+ let bytes = data. into_bytes ( ) ;
241
+ state
242
+ . cache
243
+ . file_bytes
244
+ . insert ( file_key. clone ( ) , bytes. clone ( ) ) ;
245
+ let body = Body :: from_bytes ( bytes) ;
246
+ return Response :: builder ( )
247
+ . status ( StatusCode :: OK )
248
+ . header (
249
+ "content-type" ,
250
+ deployment_file. deployment_file_mime_type . clone ( ) ,
251
+ )
252
+ . body ( body) ;
253
+ }
254
+ }
255
+ // otherwise, stream from S3
256
+ let s3_path = deployment_file. file_hash . clone ( ) ;
257
+ if let Ok ( s3_data) = state. storage . bucket . get_object_stream ( s3_path) . await {
258
+ let stream = s3_data. bytes . map ( |chunk| {
259
+ chunk. map_err ( |e| {
260
+ info ! ( "Error streaming SPA index: {}" , e) ;
261
+ std:: io:: Error :: new ( std:: io:: ErrorKind :: Other , e)
262
+ } )
263
+ } ) ;
264
+ let body = Body :: from_bytes_stream ( stream) ;
265
+ return Response :: builder ( )
266
+ . status ( StatusCode :: OK )
267
+ . header (
268
+ "content-type" ,
269
+ deployment_file. deployment_file_mime_type . clone ( ) ,
270
+ )
271
+ . body ( body) ;
272
+ }
273
+ }
274
+ // default 404 if SPA fallback failed
275
+ info ! (
276
+ "SPA fallback failed for {} {}, returning default 404" ,
277
+ host, path
278
+ ) ;
144
279
return Response :: builder ( )
145
280
. status ( StatusCode :: NOT_FOUND )
146
- . body ( Body :: from_string ( reason ) ) ;
281
+ . body ( Body :: from_string ( include_str ! ( "./404.html" ) . to_string ( ) ) ) ;
147
282
}
148
283
ResolveResult :: Error ( err) => {
149
284
info ! ( "Resolution error: {}" , err) ;
@@ -155,21 +290,26 @@ async fn resolve_http(request: &Request, state: Data<&State>) -> impl IntoRespon
155
290
}
156
291
157
292
async fn get_last_deployment ( host : & str , state : & State ) -> Result < Deployment , HttpError > {
158
- // get domain
293
+ // get domain
159
294
// get site
160
295
// get last deployment
161
296
// get file at path in deployment
162
297
// otherwise return default /index.html if exists
163
298
let domain = Domain :: existing_domain_by_name ( host, & state. clone ( ) )
164
- . await . ok ( ) . flatten ( ) ;
299
+ . await
300
+ . ok ( )
301
+ . flatten ( ) ;
165
302
166
303
if let Some ( domain) = domain {
167
304
let site = Site :: get_by_id ( & state. clone ( ) . database , & domain. site_id )
168
- . await . ok ( ) ;
305
+ . await
306
+ . ok ( ) ;
169
307
170
308
if let Some ( site) = site {
171
- let deployment = Deployment :: get_last_by_site_id ( & state. clone ( ) . database , & site. site_id )
172
- . await . ok ( ) ;
309
+ let deployment =
310
+ Deployment :: get_last_by_site_id ( & state. clone ( ) . database , & site. site_id )
311
+ . await
312
+ . ok ( ) ;
173
313
174
314
if let Some ( deployment) = deployment {
175
315
return Ok ( deployment) ;
0 commit comments