-
Notifications
You must be signed in to change notification settings - Fork 12
feat: support @19 #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: support @19 #216
Conversation
✅ Deploy Preview for plugin-angular-universal-demo ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
…able CommonEngine
…veloper Preview App Engine
7fe10d0
to
a50958a
Compare
a50958a
to
97b690b
Compare
import { RenderMode, ServerRoute } from '@angular/ssr' | ||
|
||
export const serverRoutes: ServerRoute[] = [ | ||
{ path: 'detail/:id', renderMode: RenderMode.Server }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without providing this configuration Angular builds alone were failing with:
✘ [ERROR] The 'detail/:id' route uses prerendering and includes parameters, but 'getPrerenderParams' is missing. Please define 'getPrerenderParams' function for this route in your server routing configuration or specify a different 'renderMode'.
Note - this would be good opportunity to test Prerendering with fallback ( https://ng-dev-previews-fw--pr-angular-angular-58445-adev-prev-gwy7p64b.web.app/guide/hybrid-rendering#prerender-fallback-strategies), but it currently doesn't actually work:
⠴ Building...
✘ [ERROR] No matching export in "node_modules/@angular/ssr/fesm2022/ssr.mjs" for import "PrerenderFallback"
src/app/app.routes.server.ts:2:21:
2 │ import { RenderMode, PrerenderFallback } from '@angular/ssr';
╵ ~~~~~~~~~~~~~~~~~
⠦ Building...
(TS types also suggest this named export should exist)
…er.ts" This reverts commit 8a7a7cb.
1a658c7
to
da1bc66
Compare
src/helpers/serverModuleHelpers.js
Outdated
const NetlifyServerTsAppEngine = /* typescript */ `import { AngularAppEngine, createRequestHandler } from '@angular/ssr' | ||
const angularAppEngine = new AngularAppEngine() | ||
|
||
// @ts-expect-error - createRequestHandler expects a function with single Request argument and doesn't allow context argument |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly this @ts-expect-error
directive will be no longer needed IF angular/angular-cli#28845 would be accepted
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would we need a @ts-except-error
in a generated file in the first place? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Types are checked at ng build
time and it would fail otherwise - see https://github.com/netlify/angular-runtime/actions/runs/11798216865/job/32863885795#step:6:1537 for example before I (re)added that @ts-expect-error
directive.
There are options to change that ( angular/angular-cli#19948 ) but that's not the default.
There are some ways to maybe do things differently:
reqHandler
named export is the one that would be used for dev server - and this one NEED to usecreateRequestHandler
wrapped handler - given dev we wouldn't be getting context, so maybe this one could use userequest
- use other export for prod handler in which case we don't need to use
createRequestHandler
wrapping and no types to adhere to
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I think I figured workaround - we can grab context from https://docs.netlify.com/edge-functions/api/#netlify-global-object and then we should be able to skip the type problem here by just not getting context from 2nd argument for edge functions and only grab request (1st param) which would be compatible with Angular's createRequestHandler
without adjusting type like in my optimistic PR to @angular/ssr
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did end up making it work without those @ts-expect-error
directives with usage of global Netlify object, but it still is not the best as context
is typed as any
because I can't use actual recent @netlify/edge-functions
(that contain context on global object) because they reference Deno
global types which Angular compiler doesn't know about those - users could install Deno types maybe to workaround this, but it does feel like some changes need to happen in @netlify/edge-functions
to make it easy to use in Angular which doesn't typecheck in ~Deno runtime. Additionally to make it work I did add new module with getContext()
export that basically just returns Netlify.context from global object, because directly trying to grab it from global Netlify was resulting in another type error in Angular land.
It generally seem like we either have to use any
or we vendor NetlifyContext
type (to not rely on @netlify/edge-functions
package which is not usable in Angular context since @netlify/edge-functions@2.10.0
which added relience on global Deno types)
src/index.js
Outdated
@@ -29,8 +37,11 @@ module.exports = { | |||
IS_LOCAL: constants.IS_LOCAL, | |||
netlifyConfig, | |||
}) | |||
|
|||
usedEngine = await fixServerTs({ angularVersion, siteRoot, failPlugin }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function does two things:
- replace server file
- return the engine that's used
Ideally I think we split up these two ideas
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can also say that this function does half (step 1) of preparing edge handler and returned value is required to inform how second step should look like.
I did look at splitting this function but result is much more boilerplate and condition checks are needed as things that were handled by control flow in single function no longer get that and whatever state representation we return require adding much more conditions explicit that currently are just implicit due to control flow which I'm not sure is actually more readable than what currently is there, but maybe I miss some obvious model here that is better in all aspects?
}) | ||
}, | ||
async onEnd() { | ||
await revertServerTsFix() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Revert again? I see we do this in onBuild
already. Could this cause file not found failures?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It flips boolean state to only revert once even if called multiple times - I just try to ensure this is executed as soon as possible (doing it onBuild
) and having it in something like finally
block to make sure it also executes on failures (onEnd
). All of it in effort to not leave user code mutations hanging after builds and also trying to do it as early as possible (for the user interruption cases)
const project = getProject(angularJson) | ||
const { | ||
architect: { build }, | ||
} = project |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This block is the same as in other file, can potentially be extracted into a helper for getting build
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would slot this in follow up - won't touch this for now given time constraints with upcoming release so all the refactor-y type things I am skipping for now
src/helpers/serverModuleHelpers.js
Outdated
* @param {string} serverModuleContents | ||
* @returns {'AppEngine' | 'CommonEngine' | undefined} | ||
*/ | ||
const getUsedEngine = function (serverModuleContents) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Anything we can hook into? This is brittle because user could have a TODO comment for example.
Perhaps something we work with the angular team on?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this seems ok as a last-minute last-resort option, but it's beyond brittle and leaky and realistic scenarios that would break are easy to imagine (not purely hypothetical):
// TODO Switch to AppEngine when stable
import { CommonEngine } from '@angular/ssr/node'
/* ... */
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the things about known content ( #216 (comment) ) I think what seems most reasonable is to do this:
- Compare against known default variants of
server.ts
file and if those match exactly - we can swap automatically as then we know there are no customizations there and our default is 1:1 replacement. - If
server.ts
is unknown - we can still try to find those keywords - but if bothCommonEngine
andAppEngine
keywords are found - like in example above - the result of checking is "not sure". But we wouldn't use this information (even if just CommonEngine or AppEngine is found) and instead we only use this information to generate meaningful error message - if single engine is used - error message just show content theirserver.ts
should have to use given engine - if no engines or both are found - we display both variants in error message (or we just display both variants always and just mention engine we discovered as most likely what "user wants" to not hide any information - just attempt to prioritize one we ~think is most relevant to user setup.
The part that is not clear to me however is how exactly I could check for wether current server.ts
file is fine for our edge handler usage (aka wether user already use module in Netlify compatible way) - for now this is done by checking if express
keyword is used which has pretty much all the same problems as selecting engine that is being used (easy to produce false positives)
src/helpers/serverModuleHelpers.js
Outdated
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') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once again this is quite brittle, maybe something we can hook into something.
This captures words that contain express as well.
What happens if the user customized this and added express on their own?
Maybe we can work with the framework team to improve this, perhaps we can even offer to open a PR for them
const { getProject } = require('./setUpEdgeFunction') | ||
|
||
// eslint-disable-next-line no-inline-comments | ||
const NetlifyServerTsCommonEngine = /* typescript */ `import { CommonEngine } from '@angular/ssr/node' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously not in this PR, is anything blocking us moving to TS for our runtime code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Obviously not in this PR, is anything blocking us moving to TS for our runtime code?
the /* typescript */
~pragma is just something that treats content of following string literal in compatible editors that support it - for stuff like this - https://marketplace.visualstudio.com/items?itemName=bierner.comment-tagged-templates
It doesn't have any effect otherwise.
As for moving entire code of angular runtime to typescript - this is completely separate and I don't think there is blocker per se other than justifying this work being done - it would require migrating files and adding a build step/tools until stuff like https://nodejs.org/en/learn/typescript/run-natively is stable which I think is completely out of scope for this.
I'm not sure how much benefit we would see here because angular runtime itself is not overly complex and it uses jsdoc already - maybe in between solution would be to use // @ts-check
to enable typechecking and ensure that jsdocs are indeed correct and everything is type safe ( https://www.typescriptlang.org/docs/handbook/intro-to-js-ts.html ), but still not in scope for this PR I would say
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Impressive work figuring all this out! 🤯
src/helpers/serverModuleHelpers.js
Outdated
const NetlifyServerTsAppEngine = /* typescript */ `import { AngularAppEngine, createRequestHandler } from '@angular/ssr' | ||
const angularAppEngine = new AngularAppEngine() | ||
|
||
// @ts-expect-error - createRequestHandler expects a function with single Request argument and doesn't allow context argument |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would we need a @ts-except-error
in a generated file in the first place? 🤔
let needSwapping = false | ||
let serverModuleLocation | ||
let serverModuleBackupLocation |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😬 hrm, module scope state? I guess since this is all running as part of a build plugin it can ever run twice without a process lifecycle, so it's probably ok. If it's easy I'd suggest refactoring it away though, since it's harder to reason about and introduces potential for future bugs and test nondeterminism.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not super happy about this, but I just followed with the way we held state between build hooks already ( https://github.com/netlify/angular-runtime/blob/main/src/index.js#L8 ). I also don't really see good way to handle it that doesn't ultimately rely on holding state in module or global scope (that might just be abstracted away at first glance)
src/helpers/serverModuleHelpers.js
Outdated
* @param {string} serverModuleContents | ||
* @returns {'AppEngine' | 'CommonEngine' | undefined} | ||
*/ | ||
const getUsedEngine = function (serverModuleContents) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, this seems ok as a last-minute last-resort option, but it's beyond brittle and leaky and realistic scenarios that would break are easy to imagine (not purely hypothetical):
// TODO Switch to AppEngine when stable
import { CommonEngine } from '@angular/ssr/node'
/* ... */
src/helpers/serverModuleHelpers.js
Outdated
* | ||
* @returns {'AppEngine' | 'CommonEngine' | undefined} | ||
*/ | ||
const fixServerTs = async function ({ angularVersion, siteRoot, failPlugin }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quietly swapping out a user's file for another — potentially not running some of their custom code — doesn't seem ideal.
Is there any way we can differentiate between a fully untouched scaffolded default server.ts
and a customized one, and only replace the former and throw an actionable error on the latter?
😬 I remember seeing that Vercel does this in their Remix adapter: https://github.com/vercel/remix/blob/d8eb9b02a8f803b11272fecbe037725750d0f510/packages/vercel-remix/vite.ts#L100-L109. They have to keep sha checksums of every template that's ever existed. 😰
... Can we chat with the Angular team about ways to make this and the engine check robust instead of relying on brittle hacks?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The general gist about silent swapping user code I do agree with, but this is essentially what we do already for Angular 17 and 18 in practice (we just not even use this file, despite users potentially applying customizations there) so this is not something we lose support for as we just never had it - just wanted to make sure this is known.
With above said - as we only care about this module for Angular 19 and later - we probably would be fine with just having ~hashes for the boilerplate that's generated with Angular 19 and treat older as unknown and force users to use one of ours then? Possible try generating current 17 and 18 boiler plate (which would only have CommonEngine) and hash them as CommonEngine ones.
More involved thing would be to look at https://github.com/angular/angular-cli/tree/main/packages/schematics/angular/ssr/files and server.ts.template
files in those directories and try to hash those (after replacing vars with defaults?) with various git revisions.
The main question is just balance of what we supported historically and wether we want to go with:
- fail build on unknown
server.ts
forcing user to adjust their code even if they have no actual customizations (maybe they have same code just reformatted due to adjusting prettier/editor settings in which case hashes wouldn't match with known ones) - don't fail build and try to be smart - this does lose potential customization but otherwise it ~"just work" and it's inline with current behavior for 17 and 18
... Can we chat with the Angular team about ways to make this and the engine check robust instead of relying on brittle hacks?
The difficult part here that the swap kind of has to happen before angular compilation - if we wouldn't want to swap automatically - then we could use some information from Angular Build (if it exist) to inform about build failure message (tailor the message to engine used in that module), but we wouldn't be able to use it to automate swapping (unless we hook into compilation itself)
src/helpers/serverModuleHelpers.js
Outdated
if (usedEngine === 'CommonEngine') { | ||
await writeFile(serverModuleLocation, NetlifyServerTsCommonEngine) | ||
} else if (usedEngine === 'AppEngine') { | ||
await writeFile(serverModuleLocation, NetlifyServerTsAppEngine) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps just if/else because we've already forced it to be one or the other?
Are we protecting against future changes here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is just to make it super straight forward to follow logic without having to keep in mind that usedEngine
at this point will be one of those 2 - there's not more to it
…th actionable error
cbd6460
to
69dbcdf
Compare
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this error case can be seen in https://app.netlify.com/sites/plugin-angular-universal-demo/deploys/673b7bee10573500083ea36f
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💯
This adds support for Angular@19
It maintains same mechanism for Angular@17 and Angular@18 is it does currently, but for Angular@19 projects different setup is used.
The previous setup just no longer works (module we are relying on in build output is no longer produced), so we had to find alternative. With adjustments here we are inspecting user-space
server.ts
file and if it's a default one (search string forexpress
in module content) we swap it out for one of our own that is compatible with Netlify edge function handler (+ some additional glue code that adds inline config etc).With this change we are now using proper Public API so we should overall safe for any upcoming changes.
CommonEngine
vsAppEngine
:server.ts
to use either one of those and we will pick this up and select appropriate config