Skip to content

Commit

Permalink
feat: only autoswap for known module content, otherwise fail build wi…
Browse files Browse the repository at this point in the history
…th actionable error
  • Loading branch information
pieh committed Nov 18, 2024
1 parent e5036fd commit 69dbcdf
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 39 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,10 @@ If you did not opt into the App Engine Developer Preview:
```ts
import { CommonEngine } from '@angular/ssr/node'
import { render } from '@netlify/angular-runtime/common-engine'
import type { Context } from "@netlify/edge-functions"

const commonEngine = new CommonEngine()

export default async function HttpHandler(request: Request, context: Context): Promise<Response> {
// customize if you want to have custom request handling by checking request.url
// and returning instance of Response

export async function netlifyCommonEngineHandler(request: Request, context: any): Promise<Response> {
return await render(commonEngine)
}
```
Expand All @@ -127,17 +123,21 @@ If you opted into the App Engine Developer Preview:

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

const angularAppEngine = new AngularAppEngine()

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

const result = await angularAppEngine.handle(request, context)
return result || new Response('Not found', { status: 404 })
})
}

/**
* The request handler used by the Angular CLI (dev-server and during build).
*/
export const reqHandler = createRequestHandler(netlifyAppEngineHandler)
```

### Limitations
Expand Down
2 changes: 1 addition & 1 deletion demo/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
],
"scripts": [],
"server": "src/main.server.ts",
"prerender": true,
"outputMode": "server",
"ssr": {
"entry": "server.ts"
}
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"./common-engine": {
"types": "./src/common-engine.d.ts",
"default": "./src/common-engine.mjs"
},
"./context": {
"types": "./src/context.d.ts",
"default": "./src/context.mjs"
}
},
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions src/context.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare function getContext(): any
2 changes: 2 additions & 0 deletions src/context.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line no-undef
export const getContext = async () => Netlify?.context
111 changes: 88 additions & 23 deletions src/helpers/serverModuleHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const { parse, join } = require('node:path')
const { satisfies } = require('semver')

const getAngularJson = require('./getAngularJson')
const { getEngineBasedOnKnownSignatures } = require('./serverTsSignature')
const { getProject } = require('./setUpEdgeFunction')

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

// eslint-disable-next-line no-inline-comments
const NetlifyServerTsAppEngine = /* typescript */ `import { AngularAppEngine, createRequestHandler } from '@angular/ssr'
import { getContext } from '@netlify/angular-runtime/context'
const angularAppEngine = new AngularAppEngine()
// @ts-expect-error - createRequestHandler expects a function with single Request argument and doesn't allow context argument
export const reqHandler = createRequestHandler(async (request: Request, context: any) => {
export async function netlifyAppEngineHandler(request: Request): Promise<Response> {
const context = getContext()
const result = await angularAppEngine.handle(request, context)
return result || new Response('Not found', { status: 404 })
})
}
/**
* The request handler used by the Angular CLI (dev-server and during build).
*/
export const reqHandler = createRequestHandler(netlifyAppEngineHandler)
`

let needSwapping = false
Expand All @@ -38,30 +47,44 @@ let serverModuleBackupLocation
* @param {string} serverModuleContents
* @returns {'AppEngine' | 'CommonEngine' | undefined}
*/
const getUsedEngine = function (serverModuleContents) {
if (serverModuleContents.includes('AngularAppEngine') || serverModuleContents.includes('AngularNodeAppEngine')) {
const guessUsedEngine = function (serverModuleContents) {
const containsAppEngineKeywords =
serverModuleContents.includes('AngularAppEngine') || serverModuleContents.includes('AngularNodeAppEngine')
const containsCommonEngineKeywords = serverModuleContents.includes('CommonEngine')

if (containsAppEngineKeywords && containsCommonEngineKeywords) {
// keywords for both engine found - we can't determine which one is used
return
}

if (containsAppEngineKeywords) {
return 'AppEngine'
}

if (serverModuleContents.includes('CommonEngine')) {
if (containsCommonEngineKeywords) {
return 'CommonEngine'
}

// no keywords found - we can't determine which engine is used
}

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

serverModuleLocation = build?.options?.ssr?.entry
if (!serverModuleLocation || !existsSync(serverModuleLocation)) {
console.log('No SSR setup.')
return
}

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

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

if (needSwapping) {
console.log(`Swapping server.ts to use ${usedEngine}`)
console.log(
`Default server.ts using ${usedEngineBasedOnKnownSignatures} found. Automatically swapping to Netlify compatible server.ts.`,
)

const parsed = parse(serverModuleLocation)

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

await rename(serverModuleLocation, serverModuleBackupLocation)

if (usedEngine === 'CommonEngine') {
if (usedEngineBasedOnKnownSignatures === 'CommonEngine') {
await writeFile(serverModuleLocation, NetlifyServerTsCommonEngine)
} else if (usedEngine === 'AppEngine') {
} else if (usedEngineBasedOnKnownSignatures === 'AppEngine') {
await writeFile(serverModuleLocation, NetlifyServerTsAppEngine)
}
return usedEngineBasedOnKnownSignatures
}

// if we can't determine engine based on known signatures, let's first try to check if module is already
// Netlify compatible to determine if it can be used as is or if user intervention is required
// we will look for "netlify<Engine>Handler" which is named export that we will rely on and it's existence will
// be quite strong indicator that module is already compatible and doesn't require any changes

const isNetlifyAppEngine = serverModuleContents.includes('netlifyAppEngineHandler')
const isNetlifyCommonEngine = serverModuleContents.includes('netlifyCommonEngineHandler')

if (isNetlifyAppEngine && isNetlifyCommonEngine) {
// both exports found - we can't determine which engine is used
failBuild(
"server.ts seems to contain both 'netlifyAppEngineHandler' and 'netlifyCommonEngineHandler' - it should contain just one of those.",
)
}

if (isNetlifyAppEngine) {
return 'AppEngine'
}

if (isNetlifyCommonEngine) {
return 'CommonEngine'
}

// at this point we know that user's server.ts is not Netlify compatible so user intervention is required
// we will try to inspect server.ts to determine which engine is used and provide more accurate error message
const guessedUsedEngine = guessUsedEngine(serverModuleContents)

let errorMessage = `server.ts doesn't seem to be Netlify compatible and is not known default. Please replace it with Netlify compatible server.ts.`
if (guessedUsedEngine) {
const alternativeEngine = guessedUsedEngine === 'AppEngine' ? 'CommonEngine' : 'AppEngine'

errorMessage += `\n\nIt seems like you use "${guessedUsedEngine}" - for this case your server.ts file should contain following:\n\n\`\`\`\n${
guessedUsedEngine === 'CommonEngine' ? NetlifyServerTsCommonEngine : NetlifyServerTsAppEngine
}\`\`\``
errorMessage += `\n\nIf you want to use "${alternativeEngine}" instead - your server.ts file should contain following:\n\n\`\`\`\n${
alternativeEngine === 'CommonEngine' ? NetlifyServerTsCommonEngine : NetlifyServerTsAppEngine
}\`\`\``
} else {
errorMessage += `\n\nIf you want to use "CommonEngine" - your server.ts file should contain following:\n\n\`\`\`\n${NetlifyServerTsCommonEngine}\`\`\``
errorMessage += `\n\nIf you want to use "AppEngine" - your server.ts file should contain following:\n\n\`\`\`\n${NetlifyServerTsAppEngine}\`\`\``
}

return usedEngine
failBuild(errorMessage)
}

module.exports.fixServerTs = fixServerTs
Expand Down
8 changes: 4 additions & 4 deletions src/helpers/setUpEdgeFunction.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const setUpEdgeFunction = async ({ outputDir, constants, failBuild, usedEngine }
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import Handler from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
import { netlifyCommonEngineHandler } from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
import bootstrap from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/main.server.mjs";
import "./fixup-event.mjs";
Expand Down Expand Up @@ -185,17 +185,17 @@ const setUpEdgeFunction = async ({ outputDir, constants, failBuild, usedEngine }
}
return commonEngineArgsAsyncLocalStorage.run(commonEngineRenderArgs, async () => {
return await Handler(request, context);
return await netlifyCommonEngineHandler(request, context);
})
}
`
} else if (usedEngine === 'AppEngine') {
// eslint-disable-next-line no-inline-comments
ssrFunctionContent = /* javascript */ `
import { reqHandler } from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
import { netlifyAppEngineHandler } from "${toPosix(relative(edgeFunctionDir, serverDistRoot))}/server.mjs";
import "./fixup-event.mjs";
export default reqHandler;
export default netlifyAppEngineHandler;
`
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ module.exports = {
netlifyConfig,
})

usedEngine = await fixServerTs({ angularVersion, siteRoot, failPlugin })
usedEngine = await fixServerTs({ angularVersion, siteRoot, failPlugin, failBuild })
},
async onBuild({ utils, netlifyConfig, constants }) {
await revertServerTsFix()
Expand Down

0 comments on commit 69dbcdf

Please sign in to comment.