Skip to content

Commit 69dbcdf

Browse files
committed
feat: only autoswap for known module content, otherwise fail build with actionable error
1 parent e5036fd commit 69dbcdf

File tree

8 files changed

+111
-39
lines changed

8 files changed

+111
-39
lines changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,10 @@ If you did not opt into the App Engine Developer Preview:
111111
```ts
112112
import { CommonEngine } from '@angular/ssr/node'
113113
import { render } from '@netlify/angular-runtime/common-engine'
114-
import type { Context } from "@netlify/edge-functions"
115114

116115
const commonEngine = new CommonEngine()
117116

118-
export default async function HttpHandler(request: Request, context: Context): Promise<Response> {
119-
// customize if you want to have custom request handling by checking request.url
120-
// and returning instance of Response
121-
117+
export async function netlifyCommonEngineHandler(request: Request, context: any): Promise<Response> {
122118
return await render(commonEngine)
123119
}
124120
```
@@ -127,17 +123,21 @@ If you opted into the App Engine Developer Preview:
127123

128124
```ts
129125
import { AngularAppEngine, createRequestHandler } from '@angular/ssr'
130-
import type { Context } from "@netlify/edge-functions"
126+
import { getContext } from '@netlify/angular-runtime/context'
131127

132128
const angularAppEngine = new AngularAppEngine()
133129

134-
export const reqHandler = createRequestHandler(async (request: Request, context: Context) => {
135-
// customize if you want to have custom request handling by checking request.url
136-
// and returning instance of Response
130+
export async function netlifyAppEngineHandler(request: Request): Promise<Response> {
131+
const context = getContext()
137132

138133
const result = await angularAppEngine.handle(request, context)
139134
return result || new Response('Not found', { status: 404 })
140-
})
135+
}
136+
137+
/**
138+
* The request handler used by the Angular CLI (dev-server and during build).
139+
*/
140+
export const reqHandler = createRequestHandler(netlifyAppEngineHandler)
141141
```
142142

143143
### Limitations

demo/angular.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
],
3030
"scripts": [],
3131
"server": "src/main.server.ts",
32-
"prerender": true,
32+
"outputMode": "server",
3333
"ssr": {
3434
"entry": "server.ts"
3535
}

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
"./common-engine": {
2020
"types": "./src/common-engine.d.ts",
2121
"default": "./src/common-engine.mjs"
22+
},
23+
"./context": {
24+
"types": "./src/context.d.ts",
25+
"default": "./src/context.mjs"
2226
}
2327
},
2428
"scripts": {

src/context.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export declare function getContext(): any

src/context.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// eslint-disable-next-line no-undef
2+
export const getContext = async () => Netlify?.context

src/helpers/serverModuleHelpers.js

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { parse, join } = require('node:path')
55
const { satisfies } = require('semver')
66

77
const getAngularJson = require('./getAngularJson')
8+
const { getEngineBasedOnKnownSignatures } = require('./serverTsSignature')
89
const { getProject } = require('./setUpEdgeFunction')
910

1011
// eslint-disable-next-line no-inline-comments
@@ -13,20 +14,28 @@ import { render } from '@netlify/angular-runtime/common-engine'
1314
1415
const commonEngine = new CommonEngine()
1516
16-
export default async function HttpHandler(request: Request, context: any): Promise<Response> {
17+
export async function netlifyCommonEngineHandler(request: Request, context: any): Promise<Response> {
1718
return await render(commonEngine)
1819
}
1920
`
2021

2122
// eslint-disable-next-line no-inline-comments
2223
const NetlifyServerTsAppEngine = /* typescript */ `import { AngularAppEngine, createRequestHandler } from '@angular/ssr'
24+
import { getContext } from '@netlify/angular-runtime/context'
25+
2326
const angularAppEngine = new AngularAppEngine()
2427
25-
// @ts-expect-error - createRequestHandler expects a function with single Request argument and doesn't allow context argument
26-
export const reqHandler = createRequestHandler(async (request: Request, context: any) => {
28+
export async function netlifyAppEngineHandler(request: Request): Promise<Response> {
29+
const context = getContext()
30+
2731
const result = await angularAppEngine.handle(request, context)
2832
return result || new Response('Not found', { status: 404 })
29-
})
33+
}
34+
35+
/**
36+
* The request handler used by the Angular CLI (dev-server and during build).
37+
*/
38+
export const reqHandler = createRequestHandler(netlifyAppEngineHandler)
3039
`
3140

3241
let needSwapping = false
@@ -38,30 +47,44 @@ let serverModuleBackupLocation
3847
* @param {string} serverModuleContents
3948
* @returns {'AppEngine' | 'CommonEngine' | undefined}
4049
*/
41-
const getUsedEngine = function (serverModuleContents) {
42-
if (serverModuleContents.includes('AngularAppEngine') || serverModuleContents.includes('AngularNodeAppEngine')) {
50+
const guessUsedEngine = function (serverModuleContents) {
51+
const containsAppEngineKeywords =
52+
serverModuleContents.includes('AngularAppEngine') || serverModuleContents.includes('AngularNodeAppEngine')
53+
const containsCommonEngineKeywords = serverModuleContents.includes('CommonEngine')
54+
55+
if (containsAppEngineKeywords && containsCommonEngineKeywords) {
56+
// keywords for both engine found - we can't determine which one is used
57+
return
58+
}
59+
60+
if (containsAppEngineKeywords) {
4361
return 'AppEngine'
4462
}
4563

46-
if (serverModuleContents.includes('CommonEngine')) {
64+
if (containsCommonEngineKeywords) {
4765
return 'CommonEngine'
4866
}
67+
68+
// no keywords found - we can't determine which engine is used
4969
}
5070

5171
/**
52-
* For Angular@19+ we inspect user's server.ts and if it uses express, we swap it out with our own.
53-
* We also check wether CommonEngine or AppEngine is used to provide correct replacement preserving
54-
* engine of user's choice (CommonEngine is stable, but lacks support for some features, AppEngine is
55-
* Developer Preview, but has more features and is easier to integrate with - ultimately choice is up to user
56-
* as AppEngine might have breaking changes outside of major version bumps)
72+
* For Angular@19+ we inspect user's server.ts and if it's one of known defaults that are generated when scaffolding
73+
* new Angular app with SSR enabled - we will automatically swap it out with Netlify compatible server.ts using same Angular
74+
* Engine. Swapping just known server.ts files ensures that we are not losing any customizations user might have made.
75+
* In case server.ts file is not known and our checks decide that it's not Netlify compatible (we are looking for specific keywords
76+
* that would be used for named exports) - we will fail the build and provide user with instructions on how to replace server.ts
77+
* to make it Netlify compatible and which they can apply request handling customizations to it (or just leave default in if they generally
78+
* have default one that just missed our known defaults comparison potentially due to custom formatting etc).
5779
* @param {Object} obj
5880
* @param {string} obj.angularVersion Angular version
5981
* @param {string} obj.siteRoot Root directory of an app
6082
* @param {(msg: string) => never} obj.failPlugin Function to fail the plugin
83+
* * @param {(msg: string) => never} obj.failBuild Function to fail the build
6184
*
6285
* @returns {'AppEngine' | 'CommonEngine' | undefined}
6386
*/
64-
const fixServerTs = async function ({ angularVersion, siteRoot, failPlugin }) {
87+
const fixServerTs = async function ({ angularVersion, siteRoot, failPlugin, failBuild }) {
6588
if (!satisfies(angularVersion, '>=19.0.0-rc', { includePrerelease: true })) {
6689
// for pre-19 versions, we don't need to do anything
6790
return
@@ -76,35 +99,77 @@ const fixServerTs = async function ({ angularVersion, siteRoot, failPlugin }) {
7699

77100
serverModuleLocation = build?.options?.ssr?.entry
78101
if (!serverModuleLocation || !existsSync(serverModuleLocation)) {
79-
console.log('No SSR setup.')
80102
return
81103
}
82104

83105
// check wether project is using stable CommonEngine or Developer Preview AppEngine
84106
const serverModuleContents = await readFile(serverModuleLocation, 'utf8')
85-
/** @type {'AppEngine' | 'CommonEngine'} */
86-
const usedEngine = getUsedEngine(serverModuleContents) ?? 'CommonEngine'
87107

88-
// if server module uses express - it means we can't use it and instead we need to provide our own
89-
needSwapping = serverModuleContents.includes('express')
108+
const usedEngineBasedOnKnownSignatures = getEngineBasedOnKnownSignatures(serverModuleContents)
109+
if (usedEngineBasedOnKnownSignatures) {
110+
needSwapping = true
90111

91-
if (needSwapping) {
92-
console.log(`Swapping server.ts to use ${usedEngine}`)
112+
console.log(
113+
`Default server.ts using ${usedEngineBasedOnKnownSignatures} found. Automatically swapping to Netlify compatible server.ts.`,
114+
)
93115

94116
const parsed = parse(serverModuleLocation)
95117

96118
serverModuleBackupLocation = join(parsed.dir, `${parsed.name}.original${parsed.ext}`)
97119

98120
await rename(serverModuleLocation, serverModuleBackupLocation)
99121

100-
if (usedEngine === 'CommonEngine') {
122+
if (usedEngineBasedOnKnownSignatures === 'CommonEngine') {
101123
await writeFile(serverModuleLocation, NetlifyServerTsCommonEngine)
102-
} else if (usedEngine === 'AppEngine') {
124+
} else if (usedEngineBasedOnKnownSignatures === 'AppEngine') {
103125
await writeFile(serverModuleLocation, NetlifyServerTsAppEngine)
104126
}
127+
return usedEngineBasedOnKnownSignatures
128+
}
129+
130+
// if we can't determine engine based on known signatures, let's first try to check if module is already
131+
// Netlify compatible to determine if it can be used as is or if user intervention is required
132+
// we will look for "netlify<Engine>Handler" which is named export that we will rely on and it's existence will
133+
// be quite strong indicator that module is already compatible and doesn't require any changes
134+
135+
const isNetlifyAppEngine = serverModuleContents.includes('netlifyAppEngineHandler')
136+
const isNetlifyCommonEngine = serverModuleContents.includes('netlifyCommonEngineHandler')
137+
138+
if (isNetlifyAppEngine && isNetlifyCommonEngine) {
139+
// both exports found - we can't determine which engine is used
140+
failBuild(
141+
"server.ts seems to contain both 'netlifyAppEngineHandler' and 'netlifyCommonEngineHandler' - it should contain just one of those.",
142+
)
143+
}
144+
145+
if (isNetlifyAppEngine) {
146+
return 'AppEngine'
147+
}
148+
149+
if (isNetlifyCommonEngine) {
150+
return 'CommonEngine'
151+
}
152+
153+
// at this point we know that user's server.ts is not Netlify compatible so user intervention is required
154+
// we will try to inspect server.ts to determine which engine is used and provide more accurate error message
155+
const guessedUsedEngine = guessUsedEngine(serverModuleContents)
156+
157+
let errorMessage = `server.ts doesn't seem to be Netlify compatible and is not known default. Please replace it with Netlify compatible server.ts.`
158+
if (guessedUsedEngine) {
159+
const alternativeEngine = guessedUsedEngine === 'AppEngine' ? 'CommonEngine' : 'AppEngine'
160+
161+
errorMessage += `\n\nIt seems like you use "${guessedUsedEngine}" - for this case your server.ts file should contain following:\n\n\`\`\`\n${
162+
guessedUsedEngine === 'CommonEngine' ? NetlifyServerTsCommonEngine : NetlifyServerTsAppEngine
163+
}\`\`\``
164+
errorMessage += `\n\nIf you want to use "${alternativeEngine}" instead - your server.ts file should contain following:\n\n\`\`\`\n${
165+
alternativeEngine === 'CommonEngine' ? NetlifyServerTsCommonEngine : NetlifyServerTsAppEngine
166+
}\`\`\``
167+
} else {
168+
errorMessage += `\n\nIf you want to use "CommonEngine" - your server.ts file should contain following:\n\n\`\`\`\n${NetlifyServerTsCommonEngine}\`\`\``
169+
errorMessage += `\n\nIf you want to use "AppEngine" - your server.ts file should contain following:\n\n\`\`\`\n${NetlifyServerTsAppEngine}\`\`\``
105170
}
106171

107-
return usedEngine
172+
failBuild(errorMessage)
108173
}
109174

110175
module.exports.fixServerTs = fixServerTs

src/helpers/setUpEdgeFunction.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ const setUpEdgeFunction = async ({ outputDir, constants, failBuild, usedEngine }
125125
import { dirname, relative, resolve } from 'node:path';
126126
import { fileURLToPath } from 'node:url';
127127
128-
import Handler from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
128+
import { netlifyCommonEngineHandler } from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
129129
import bootstrap from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/main.server.mjs";
130130
import "./fixup-event.mjs";
131131
@@ -185,17 +185,17 @@ const setUpEdgeFunction = async ({ outputDir, constants, failBuild, usedEngine }
185185
}
186186
187187
return commonEngineArgsAsyncLocalStorage.run(commonEngineRenderArgs, async () => {
188-
return await Handler(request, context);
188+
return await netlifyCommonEngineHandler(request, context);
189189
})
190190
}
191191
`
192192
} else if (usedEngine === 'AppEngine') {
193193
// eslint-disable-next-line no-inline-comments
194194
ssrFunctionContent = /* javascript */ `
195-
import { reqHandler } from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
195+
import { netlifyAppEngineHandler } from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
196196
import "./fixup-event.mjs";
197197
198-
export default reqHandler;
198+
export default netlifyAppEngineHandler;
199199
`
200200
}
201201

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = {
3838
netlifyConfig,
3939
})
4040

41-
usedEngine = await fixServerTs({ angularVersion, siteRoot, failPlugin })
41+
usedEngine = await fixServerTs({ angularVersion, siteRoot, failPlugin, failBuild })
4242
},
4343
async onBuild({ utils, netlifyConfig, constants }) {
4444
await revertServerTsFix()

0 commit comments

Comments
 (0)