Skip to content

Commit f6a0ac9

Browse files
committed
Switch to using dual .NET + Next.js backend
1 parent 04fda6c commit f6a0ac9

9 files changed

Lines changed: 409 additions & 102 deletions

File tree

.github/workflows/build-container.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,8 @@ jobs:
110110
env:
111111
SERVICESTACK_LICENSE: ${{ secrets.SERVICESTACK_LICENSE }}
112112
run: |
113-
dotnet publish --os linux --arch x64 -c Release -p:ContainerRepository=${{ env.image_repository_name }} -p:ContainerRegistry=ghcr.io -p:ContainerImageTags=latest -p:ContainerPort=80 -p:ContainerEnvironmentVariable="SERVICESTACK_LICENSE=${{ env.SERVICESTACK_LICENSE }}"
113+
docker build \
114+
--build-arg SERVICESTACK_LICENSE="$SERVICESTACK_LICENSE" \
115+
-t ghcr.io/${{ env.image_repository_name }}:latest \
116+
-f Dockerfile .
117+
docker push ghcr.io/${{ env.image_repository_name }}:latest

Dockerfile

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Multi-stage Dockerfile to run ASP.NET Core + Next.js in a single container
2+
3+
# 1. Build .NET app
4+
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS dotnet-build
5+
WORKDIR /src
6+
7+
# Copy solution and projects
8+
COPY TechStacks.sln ./
9+
COPY TechStacks ./TechStacks
10+
COPY TechStacks.ServiceInterface ./TechStacks.ServiceInterface
11+
COPY TechStacks.ServiceModel ./TechStacks.ServiceModel
12+
13+
# Restore and publish
14+
RUN dotnet restore
15+
RUN dotnet publish TechStacks/TechStacks.csproj -c Release -o /app/api/publish
16+
17+
# 2. Build Next.js app
18+
FROM node:20-alpine AS next-build
19+
WORKDIR /app/client
20+
21+
COPY TechStacks.Client/package*.json ./
22+
RUN npm ci
23+
COPY TechStacks.Client/ ./
24+
25+
# Build Next.js in server mode
26+
RUN npm run build:prod
27+
28+
# 3. Runtime image with .NET + Node
29+
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
30+
WORKDIR /app
31+
32+
ARG SERVICESTACK_LICENSE
33+
34+
# Install Node.js and bash for the entrypoint script
35+
RUN apt-get update \
36+
&& apt-get install -y nodejs npm bash \
37+
&& apt-get clean \
38+
&& rm -rf /var/lib/apt/lists/*
39+
40+
# Copy published .NET app
41+
COPY --from=dotnet-build /app/api/publish ./api
42+
43+
# Copy built Next.js app (including .next, node_modules, public, etc.)
44+
COPY --from=next-build /app/client ./client
45+
46+
ENV ASPNETCORE_URLS=http://0.0.0.0:8080 \
47+
NEXT_PORT=3000 \
48+
NODE_ENV=production \
49+
INTERNAL_API_URL=http://127.0.0.1:8080 \
50+
SERVICESTACK_LICENSE=$SERVICESTACK_LICENSE
51+
52+
EXPOSE 8080
53+
54+
# Copy entrypoint script
55+
COPY entrypoint.sh /app/entrypoint.sh
56+
RUN chmod +x /app/entrypoint.sh
57+
58+
ENTRYPOINT ["/usr/bin/env", "bash", "/app/entrypoint.sh"]
59+

TechStacks.Client/next.config.mjs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,15 @@ const nextConfig = {
2323
// Configure pageExtensions to include MDX files
2424
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
2525

26-
// Enable static export for production builds
27-
output: isProd ? 'export' : undefined,
28-
2926
// Change output directory from 'out' to 'dist'
3027
distDir: 'dist',
3128

32-
// Required for static export
29+
// Images are served unoptimized; adjust if you later add an image optimizer/CDN
3330
images: {
3431
unoptimized: true
3532
},
3633

37-
// Don't use trailingSlash - it causes issues with dynamic routes in static export
38-
// The C# backend's MapFallbackToFile handles routing correctly without it
34+
// Keep clean URLs without trailing slashes
3935
trailingSlash: false,
4036

4137
env: {

TechStacks.Client/server.js

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,12 @@
1-
import { createServer } from 'https'
1+
import { createServer } from 'http'
22
import { parse } from 'url'
33
import next from 'next'
44
import { createProxyMiddleware } from 'http-proxy-middleware'
5-
import fs from 'fs'
6-
import path from 'path'
7-
import child_process from 'child_process'
85

96
const dev = process.env.NODE_ENV !== 'production'
107
const hostname = 'localhost'
118
const port = 3000
129

13-
const baseFolder =
14-
process.env.APPDATA !== undefined && process.env.APPDATA !== ''
15-
? `${process.env.APPDATA}/ASP.NET/https`
16-
: `${process.env.HOME}/.aspnet/https`;
17-
18-
const certificateName = "techatacks.client";
19-
const certFilePath = path.join(baseFolder, `${certificateName}.pem`);
20-
const keyFilePath = path.join(baseFolder, `${certificateName}.key`);
21-
22-
// Generate dev certificates if they don't exist (for dev mode only)
23-
if (dev) {
24-
console.log(`Certificate path: ${certFilePath}`);
25-
26-
if (!fs.existsSync(certFilePath) || !fs.existsSync(keyFilePath)) {
27-
// mkdir to fix dotnet dev-certs error 3 https://github.com/dotnet/aspnetcore/issues/58330
28-
if (!fs.existsSync(baseFolder)) {
29-
fs.mkdirSync(baseFolder, { recursive: true });
30-
}
31-
if (
32-
0 !==
33-
child_process.spawnSync(
34-
"dotnet",
35-
[
36-
"dev-certs",
37-
"https",
38-
"--export-path",
39-
certFilePath,
40-
"--format",
41-
"Pem",
42-
"--no-password",
43-
],
44-
{ stdio: "inherit" }
45-
).status
46-
) {
47-
throw new Error("Could not create certificate.");
48-
}
49-
}
50-
}
5110

5211
const target = process.env.ASPNETCORE_HTTPS_PORT
5312
? `https://localhost:${process.env.ASPNETCORE_HTTPS_PORT}`
@@ -77,12 +36,7 @@ const apiProxy = createProxyMiddleware({
7736
})
7837

7938
app.prepare().then(() => {
80-
const serverOptions = dev ? {
81-
key: fs.readFileSync(keyFilePath),
82-
cert: fs.readFileSync(certFilePath),
83-
} : {};
84-
85-
createServer(serverOptions, async (req, res) => {
39+
createServer(async (req, res) => {
8640
try {
8741
const parsedUrl = parse(req.url, true)
8842
const { pathname } = parsedUrl
@@ -105,8 +59,7 @@ app.prepare().then(() => {
10559
process.exit(1)
10660
})
10761
.listen(port, () => {
108-
console.log(`> Ready on https://${hostname}:${port}`)
62+
console.log(`> Ready on http://${hostname}:${port}`)
10963
console.log(`> Proxying /api requests to ${target}`)
11064
})
11165
})
112-

TechStacks/Program.cs

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,27 @@
100100

101101
var app = builder.Build();
102102

103+
var nextServerBase = app.Environment.IsDevelopment()
104+
? new Uri("http://localhost:3000")
105+
: new Uri("http://127.0.0.1:3000");
106+
107+
var allowInvalidCertsForNext = false; // No HTTPS when proxying to Next internally
108+
109+
HttpMessageHandler nextHandler = allowInvalidCertsForNext
110+
? new HttpClientHandler
111+
{
112+
ServerCertificateCustomValidationCallback =
113+
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
114+
}
115+
: new HttpClientHandler();
116+
117+
var nextClient = new HttpClient(nextHandler)
118+
{
119+
BaseAddress = nextServerBase
120+
};
121+
103122
app.UseForwardedHeaders();
123+
app.UseWebSockets();
104124

105125
app.UseMigrationsEndPoint();
106126
app.UseSwagger();
@@ -113,37 +133,24 @@
113133
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
114134
app.UseHsts();
115135
app.UseHttpsRedirection();
116-
117-
// Redirect root requests to the Client App in development
118-
app.MapGet("/", async (HttpContext ctx) =>
119-
ctx.Response.Redirect("https://localhost:3000"));
120136
}
137+
else
138+
{
139+
// Production-specific middleware (if needed) can go here
140+
}
141+
142+
// After all .NET middleware has run, let Next.js handle 404s
143+
Proxy.MapNotFoundToNode(app, nextClient, ignorePaths:[
144+
"/api",
145+
"/auth",
146+
"/Identity",
147+
"/swagger",
148+
]);
121149

122150
app.UseStaticFiles();
123151
app.UseCookiePolicy();
124152
app.UseCors();
125153

126-
// Fallback for dynamic post routes without a static exported page.
127-
// If a specific post HTML exists, serve it; otherwise serve the generic
128-
// placeholder page that loads the post client-side from the API.
129-
var fallbackRoutes = new Dictionary<string, string>
130-
{
131-
["/posts/{id:long}/{slug}"] = "posts/0/_placeholder.html",
132-
["/tech/{slug}"] = "tech/_placeholder.html",
133-
["/stacks/{slug}"] = "stacks/_placeholder.html",
134-
};
135-
foreach (var route in fallbackRoutes)
136-
{
137-
app.MapGet(route.Key, (HttpContext ctx, IWebHostEnvironment env) =>
138-
{
139-
var placeholderPath = Path.Combine(env.WebRootPath, route.Value);
140-
if (File.Exists(placeholderPath))
141-
return Results.File(placeholderPath, "text/html; charset=utf-8");
142-
143-
return Results.NotFound();
144-
});
145-
}
146-
147154
// GitHub OAuth endpoint
148155
app.MapGet("/auth/github", (
149156
HttpContext context,
@@ -166,6 +173,48 @@
166173

167174
app.MapRazorPages();
168175
app.MapAdditionalIdentityEndpoints();
169-
app.MapFallbackToFile("index.html");
176+
177+
// Proxy development HMR WebSocket and fallback routes to the Next server
178+
if (app.Environment.IsDevelopment())
179+
{
180+
app.Map("/_next/webpack-hmr", async context =>
181+
{
182+
if (context.WebSockets.IsWebSocketRequest)
183+
{
184+
await Proxy.WebSocketToNode(context, nextServerBase, allowInvalidCertsForNext);
185+
}
186+
else
187+
{
188+
await Proxy.HttpToNode(context, nextClient);
189+
}
190+
});
191+
192+
// Start the Next.js dev server if the Next.js lockfile does not exist '../TechStacks.Client/dist/lock'
193+
var nextLockFile = "../TechStacks.Client/dist/lock";
194+
if (!File.Exists(nextLockFile))
195+
{
196+
Console.WriteLine("Starting Next.js dev server...");
197+
if (!Proxy.TryStartNode("../TechStacks.Client", out var process))
198+
{
199+
Console.WriteLine($"Failed to start Next.js dev server: {process.ExitCode}");
200+
return;
201+
}
202+
203+
process.Exited += (s, e) => {
204+
Console.WriteLine("[node] Exited: " + process.ExitCode);
205+
File.Delete(nextLockFile);
206+
};
207+
208+
app.Lifetime.ApplicationStopping.Register(() => {
209+
if (!process.HasExited)
210+
{
211+
process.Kill(entireProcessTree: true);
212+
}
213+
});
214+
}
215+
}
216+
217+
// Fallback: any unmatched route goes to Next.js
218+
app.MapFallback(context => Proxy.HttpToNode(context, nextClient));
170219

171220
app.Run();
Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
11
{
2-
"iisSettings": {
3-
"windowsAuthentication": false,
4-
"anonymousAuthentication": true,
5-
"iisExpress": {
6-
"applicationUrl": "https://localhost:5001/",
7-
"sslPort": 0
8-
}
9-
},
2+
"$schema": "http://json.schemastore.org/launchsettings.json",
103
"profiles": {
11-
"IIS Express": {
12-
"commandName": "IISExpress",
4+
"http": {
5+
"commandName": "Project",
6+
"dotnetRunMessages": true,
137
"launchBrowser": true,
8+
"launchUrl": "metadata",
9+
"applicationUrl": "https://localhost:5001;http://localhost:5000",
1410
"environmentVariables": {
1511
"ASPNETCORE_ENVIRONMENT": "Development"
1612
}
1713
},
18-
"TechStacks": {
14+
"https": {
1915
"commandName": "Project",
16+
"dotnetRunMessages": true,
17+
"launchBrowser": true,
18+
"launchUrl": "metadata",
19+
"applicationUrl": "https://localhost:5001;http://localhost:5000",
20+
"environmentVariables": {
21+
"ASPNETCORE_ENVIRONMENT": "Development"
22+
}
23+
},
24+
"IIS Express": {
25+
"commandName": "IISExpress",
2026
"launchBrowser": true,
27+
"launchUrl": "metadata",
2128
"environmentVariables": {
2229
"ASPNETCORE_ENVIRONMENT": "Development"
23-
},
24-
"applicationUrl": "https://localhost:5001/"
30+
}
2531
}
2632
}
2733
}
34+

0 commit comments

Comments
 (0)