Skip to content

Commit 3f66c57

Browse files
committed
Revise SPA route detection
1 parent d25506e commit 3f66c57

File tree

2 files changed

+198
-46
lines changed

2 files changed

+198
-46
lines changed

src/SIL.XForge.Scripture/Startup.cs

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.Globalization;
44
using System.IO;
5+
using System.Linq;
56
using Autofac;
67
using Autofac.Extensions.DependencyInjection;
78
using Hangfire;
@@ -34,47 +35,62 @@ public enum SpaDevServerStartup
3435

3536
public class Startup
3637
{
38+
/// <summary>
39+
/// Routes that should be handled by SPA in development but not in production.
40+
/// </summary>
3741
private static readonly HashSet<string> DevelopmentSpaGetRoutes =
3842
[
39-
"runtime.js",
40-
"runtime.js.map",
41-
"polyfills.js",
42-
"polyfills.js.map",
43-
"styles.css",
44-
"styles.css.map",
45-
"styles.js",
46-
"styles.js.map",
47-
"vendor.js",
48-
"vendor.js.map",
49-
"main.js",
50-
"main.js.map",
5143
"@vite",
5244
"@fs",
53-
"sockjs-node",
5445
"3rdpartylicenses.txt",
46+
// sockjs-node is related to communication during `ng serve`
47+
"sockjs-node",
5548
];
5649

57-
// examples of filenames are "main-es5.4e5295b95e4b6c37b696.js", "styles.a2f070be0b37085d72ba.css"
58-
private static readonly HashSet<string> ProductionSpaGetRoutes = ["polyfills", "main", "chunk", "styles"];
50+
/// <summary>
51+
/// Routes that should be handled by SPA in production but not in development
52+
/// </summary>
53+
private static readonly HashSet<string> ProductionSpaGetRoutes = [];
54+
55+
/// <summary>
56+
/// Routes that should be handled by SPA in both production and development.
57+
/// </summary>
5958
private static readonly HashSet<string> SpaGetRoutes =
6059
[
60+
"index.html",
61+
"prerendered-routes.json",
62+
"3rdpartylicenses",
63+
// PWA files
64+
"ngsw.json",
65+
"ngsw-worker.js",
66+
"offline.html",
67+
"safety-worker.js",
68+
"sf-service-worker.js",
6169
"manifest.json",
70+
// Application routes
6271
"callback",
6372
"connect-project",
6473
"login",
6574
"projects",
6675
"join",
6776
"serval-administration",
6877
"system-administration",
69-
"favicon.ico",
78+
// Asset and build files
7079
"assets",
80+
"polyfills",
81+
"main",
82+
"chunk",
83+
"styles",
84+
"en",
85+
"quill",
86+
// Lynx-related
87+
"worker",
88+
"node_modules_sillsdev_lynx",
7189
];
7290

7391
private static readonly HashSet<string> DevelopmentSpaPostRoutes = ["sockjs-node"];
7492
private static readonly HashSet<string> ProductionSpaPostRoutes = [];
7593
private static readonly HashSet<string> SpaPostRoutes = [];
76-
private const string SpaGetRoutesLynxPrefix = "node_modules_sillsdev_lynx";
77-
private const string SpaGetRoutesWorkerPrefix = "worker-";
7894

7995
public Startup(IConfiguration configuration, IWebHostEnvironment env, ILoggerFactory loggerFactory)
8096
{
@@ -320,39 +336,42 @@ IExceptionHandler exceptionHandler
320336
appLifetime.ApplicationStopped.Register(() => ApplicationContainer.Dispose());
321337
}
322338

339+
/// <summary>Is the request something that should be handled by the Angular SPA, instead of by ASP.NET?</summary>
340+
/// <remarks>
341+
/// A production environment will serve ASP.NET files in wwwroot and SPA files in dist/browser. A development
342+
/// environment will serve ASP.NET files in wwwroot, and potentially SPA files from dist or in-memory files from `ng
343+
/// serve`. Note that `ng serve` will generate different looking files depending on whether caching+prebundling is
344+
/// used or not. When testing this method, it is helpful to run curl against port 5000 vs 4200. This method is
345+
/// written with the assumption that undefined behaviour for malformed routes is not a concern for security or
346+
/// functionality.
347+
/// </remarks>
323348
internal bool IsSpaRoute(HttpContext context)
324349
{
325350
string path = context.Request.Path.Value;
326-
if (path.Length <= 1)
327-
return false;
328-
int index = path.IndexOf("/", 1);
329-
if (index == -1)
330-
index = path.Length;
331-
string prefix = path[1..index];
332-
if (
333-
!IsDevelopmentEnvironment
334-
&& (
335-
prefix.EndsWith(".js")
336-
|| prefix.EndsWith(".js.map")
337-
|| prefix.EndsWith(".css")
338-
|| prefix.EndsWith(".css.map")
339-
)
340-
)
341-
{
342-
int hashDelimiterIndex = path.IndexOf("-");
343-
prefix = prefix[..(hashDelimiterIndex - 1)];
344-
}
345-
346-
bool isLazyChunkRoute =
347-
context.Request.Method == HttpMethods.Get
348-
&& (prefix.StartsWith(SpaGetRoutesLynxPrefix) || prefix.StartsWith(SpaGetRoutesWorkerPrefix));
351+
HashSet<string> spaRoutes =
352+
context.Request.Method == HttpMethods.Get ? SpaGetRoutes
353+
: context.Request.Method == HttpMethods.Post ? SpaPostRoutes
354+
: [];
355+
356+
// SPA-handled paths will have forms like
357+
// /some-route/my-page?a=b
358+
// /polyfills-C3D4E5F6.js.map
359+
// /safety-worker.js
360+
// /@vite/client
361+
// Anything could conceivably contain a '?'.
362+
363+
// Look at what is after the first slash, and before the next slash or '?'. Match paths like /projects/123456789
364+
// as "projects", /login?a=b as "login", and /safety-worker.js as "safety-worker.js".
365+
string exact = path.Split('/', '?').ElementAtOrDefault(1) ?? "";
366+
if (spaRoutes.Contains(exact))
367+
return true;
349368

350-
if (isLazyChunkRoute)
351-
{
369+
// Then look at what is before the first dash or dot. Match paths like /polyfills-C3D4E5F6.js.map and
370+
// /polyfills.js as "polyfills".
371+
string beginning = exact.Split(['-', '.']).FirstOrDefault();
372+
if (spaRoutes.Contains(beginning))
352373
return true;
353-
}
354374

355-
return (context.Request.Method == HttpMethods.Get && SpaGetRoutes.Contains(prefix))
356-
|| (context.Request.Method == HttpMethods.Post && SpaPostRoutes.Contains(prefix));
375+
return false;
357376
}
358377
}

test/SIL.XForge.Scripture.Tests/StartupTests.cs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111

1212
namespace SIL.XForge.Scripture;
1313

14+
/// <summary>
15+
/// Represents what environment the application is running in.
16+
/// </summary>
17+
public enum RunMode
18+
{
19+
Development,
20+
Production,
21+
}
22+
1423
[TestFixture]
1524
public class StartupTests
1625
{
@@ -115,6 +124,130 @@ public void IsSpaRoute_ProductionPath_IsRoute()
115124
Assert.IsTrue(actual);
116125
}
117126

127+
private static void IsSpaRoute_Helper(string path, RunMode[] runModes, bool expected)
128+
{
129+
foreach (RunMode runMode in runModes)
130+
{
131+
var env = new TestEnvironment(runMode.ToString());
132+
env.Context.Request.Method = HttpMethods.Get;
133+
env.Context.Request.Path = new PathString(path);
134+
// SUT
135+
bool actual = env.Startup.IsSpaRoute(env.Context);
136+
Assert.AreEqual(expected, actual, $"Failed for path {path}, runMode {runMode}.");
137+
}
138+
}
139+
140+
// SPA route in production and development
141+
[TestCase("/index.html")]
142+
[TestCase("/chunk-A1B2C3D4.js")]
143+
[TestCase("/chunk-A1B2C3D4.js.map")]
144+
[TestCase("/chunk-E5F6G7H8.js")]
145+
[TestCase("/chunk-E5F6G7H8.js.map")]
146+
[TestCase("/main-Y5Z6A1B2.js")]
147+
[TestCase("/main-Y5Z6A1B2.js.map")]
148+
[TestCase("/polyfills-C3D4E5F6.js")]
149+
[TestCase("/polyfills-C3D4E5F6.js.map")]
150+
[TestCase("/styles-G7H8I9J0.css")]
151+
[TestCase("/en-M3N4O5P6.js")]
152+
[TestCase("/en-M3N4O5P6.js.map")]
153+
[TestCase("/en-Q7R8S9T0.js")]
154+
[TestCase("/en-Q7R8S9T0.js.map")]
155+
[TestCase("/quill-U1V2W3X4.js")]
156+
[TestCase("/quill-U1V2W3X4.js.map")]
157+
[TestCase("/3rdpartylicenses.txt")]
158+
[TestCase("/safety-worker.js")]
159+
[TestCase("/sf-service-worker.js")]
160+
[TestCase("/ngsw.json")]
161+
[TestCase("/ngsw-worker.js")]
162+
[TestCase("/offline.html")]
163+
[TestCase("/prerendered-routes.json")]
164+
[TestCase("/manifest.json")]
165+
[TestCase("/assets/icons/sf-192x192.png")]
166+
[TestCase("/assets/images/sf_logo_with_name_black.svg")]
167+
[TestCase("/worker-I9J0K1L2.js")]
168+
[TestCase("/worker-I9J0K1L2.js.map")]
169+
[TestCase("/worker-basic.min.js")]
170+
[TestCase("/node_modules_sillsdev_lynx")]
171+
[TestCase("/projects")]
172+
[TestCase("/projects/abc123/translate/GEN/1")]
173+
[TestCase("/login")]
174+
[TestCase("/login?sign-up=true")]
175+
[TestCase("/join/AbCd/en-GB")]
176+
[TestCase("/callback/auth0?code=1234&state=abcd")]
177+
[TestCase("/connect-project")]
178+
[TestCase("/serval-administration")]
179+
[TestCase("/system-administration")]
180+
public void IsSpaRoute_ProductionAndDevelopment_True(string path)
181+
{
182+
RunMode[] runModes = [RunMode.Production, RunMode.Development];
183+
bool expected = true;
184+
IsSpaRoute_Helper(path, runModes, expected);
185+
}
186+
187+
// SPA route in development, but not expected in production
188+
[TestCase("/@vite/client")]
189+
[TestCase("/@fs/home/user/web-xforge/src/SIL.XForge.Scripture/ClientApp/node_modules/vite/dist/client/env.mjs")]
190+
// `ng serve` with caching+prebundling can result in files like `main.js`, `polyfill.js`, and `styles.css` with no
191+
// hashes in the filename.
192+
[TestCase("/main.js")]
193+
[TestCase("/polyfills.js")]
194+
[TestCase("/styles.css")]
195+
public void IsSpaRoute_Development_True(string path)
196+
{
197+
RunMode[] runModes = [RunMode.Development];
198+
bool expected = true;
199+
IsSpaRoute_Helper(path, runModes, expected);
200+
}
201+
202+
// ASP.NET-handled path in production and development
203+
[TestCase("/favicon.ico")]
204+
[TestCase("/css/sf.min.css")]
205+
[TestCase("/images/EarthLightsSmall.jpg")]
206+
[TestCase("/scss/mixins/_breakpoints.scss")]
207+
[TestCase("/scss/sf.scss")]
208+
[TestCase("/images/multi-devices.svg")]
209+
[TestCase("/images/community-checking.svg")]
210+
[TestCase("/images/quoter.jpg")]
211+
[TestCase("/terms")]
212+
[TestCase("/privacy")]
213+
[TestCase("/lib/material-design-lite/js/material.min.js")]
214+
[TestCase("/lib/material-design-lite/css/material.sf_grey-pt_green.min.css")]
215+
[TestCase("/non-existent-page")]
216+
[TestCase("/?login")]
217+
[TestCase("/?/login")]
218+
[TestCase("//?login")]
219+
[TestCase("//?/login")]
220+
[TestCase("/??login")]
221+
[TestCase("/??/login")]
222+
[TestCase("/#login")]
223+
[TestCase("/#/login")]
224+
// It may or may not be possible for the path to be the empty string. If it is, let's have ASP.NET handle it.
225+
[TestCase("")]
226+
// The paths "/", "/Index", and "/Status/Error" are handled by ASP.NET and don't even get to IsSpaRoute. If they did
227+
// for some reason, we'll have IsSpaRoute return false.
228+
[TestCase("/")]
229+
[TestCase("/Index")]
230+
[TestCase("/Status/Error")]
231+
// Paths with a beginning that start to match an SPA path, but ultimately don't, can be handled by ASP.NET.
232+
[TestCase("/mainly")]
233+
[TestCase("/enquiry")]
234+
[TestCase("/workerbee")]
235+
public void IsSpaRoute_ProductionAndDevelopment_False(string path)
236+
{
237+
RunMode[] runModes = [RunMode.Production, RunMode.Development];
238+
bool expected = false;
239+
IsSpaRoute_Helper(path, runModes, expected);
240+
}
241+
242+
// ASP.NET-handled path in development, but not expected in production
243+
[TestCase("/_framework/aspnetcore-browser-refresh.js")]
244+
public void IsSpaRoute_Development_False(string path)
245+
{
246+
RunMode[] runModes = [RunMode.Development];
247+
bool expected = false;
248+
IsSpaRoute_Helper(path, runModes, expected);
249+
}
250+
118251
private class TestEnvironment
119252
{
120253
public TestEnvironment(string? environmentName = null)

0 commit comments

Comments
 (0)