Skip to content

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

Merged
merged 51 commits into from
Nov 19, 2024
Merged

feat: support @19 #216

merged 51 commits into from
Nov 19, 2024

Conversation

pieh
Copy link
Contributor

@pieh pieh commented Nov 4, 2024

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 for express 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 vs AppEngine:

  • CommonEngine is stable public API used to handle request handling - it works but it requires a bit of hacks (hijacking fs reads) but the biggest thing that it's stable API.
  • AppEngine is new alternative that is marked as Developer Preview (~experimental / unstable) - it's much easier to integrate with - it's also built without expecting node APIs (as ~runtime agnostic solution). It's also required to use AppEngine to make the most out of new Hybrid Rendering APIs that come with Angular 19 (also marked as "Developer Preview").
  • Because AppEngine is not yet marked as stable public API I opted to support both variants and it's up to customer to decide which one we will use - when scaffolding new Angular projects they are asked this question - after the fact they can still decide by adjusting their server.ts to use either one of those and we will pick this up and select appropriate config

Copy link

netlify bot commented Nov 4, 2024

Deploy Preview for plugin-angular-universal-demo ready!

Name Link
🔨 Latest commit 76dae2b
🔍 Latest deploy log https://app.netlify.com/sites/plugin-angular-universal-demo/deploys/673b816981618500088c3c78
😎 Deploy Preview https://deploy-preview-216--plugin-angular-universal-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@pieh pieh force-pushed the feat/support-v19 branch from 7fe10d0 to a50958a Compare November 7, 2024 08:29
@pieh pieh force-pushed the feat/support-v19 branch from a50958a to 97b690b Compare November 7, 2024 08:51
import { RenderMode, ServerRoute } from '@angular/ssr'

export const serverRoutes: ServerRoute[] = [
{ path: 'detail/:id', renderMode: RenderMode.Server },
Copy link
Contributor Author

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)

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
Copy link
Contributor Author

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

Copy link
Contributor

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? 🤔

Copy link
Contributor Author

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 use createRequestHandler wrapped handler - given dev we wouldn't be getting context, so maybe this one could use use request
  • use other export for prod handler in which case we don't need to use createRequestHandler wrapping and no types to adhere to

Copy link
Contributor Author

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

Copy link
Contributor Author

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)

@pieh pieh changed the title WIP feat: support @19 feat: support @19 Nov 12, 2024
@github-actions github-actions bot added the type: feature code contributing to the implementation of a feature and/or user facing functionality label Nov 12, 2024
@pieh pieh marked this pull request as ready for review November 12, 2024 16:47
@pieh pieh requested a review from a team as a code owner November 12, 2024 16:47
src/index.js Outdated
@@ -29,8 +37,11 @@ module.exports = {
IS_LOCAL: constants.IS_LOCAL,
netlifyConfig,
})

usedEngine = await fixServerTs({ angularVersion, siteRoot, failPlugin })
Copy link
Contributor

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

Copy link
Contributor Author

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()
Copy link
Contributor

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?

Copy link
Contributor Author

@pieh pieh Nov 18, 2024

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
Copy link
Contributor

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

Copy link
Contributor Author

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

* @param {string} serverModuleContents
* @returns {'AppEngine' | 'CommonEngine' | undefined}
*/
const getUsedEngine = function (serverModuleContents) {
Copy link
Contributor

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?

Copy link
Contributor

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'
/* ... */

Copy link
Contributor Author

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:

  1. 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.
  2. If server.ts is unknown - we can still try to find those keywords - but if both CommonEngine and AppEngine 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 their server.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)

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')
Copy link
Contributor

@mrstork mrstork Nov 14, 2024

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'
Copy link
Contributor

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?

Copy link
Contributor Author

@pieh pieh Nov 18, 2024

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

Copy link
Contributor

@serhalp serhalp left a 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! 🤯

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
Copy link
Contributor

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? 🤔

Comment on lines +32 to +34
let needSwapping = false
let serverModuleLocation
let serverModuleBackupLocation
Copy link
Contributor

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.

Copy link
Contributor Author

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)

* @param {string} serverModuleContents
* @returns {'AppEngine' | 'CommonEngine' | undefined}
*/
const getUsedEngine = function (serverModuleContents) {
Copy link
Contributor

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'
/* ... */

*
* @returns {'AppEngine' | 'CommonEngine' | undefined}
*/
const fixServerTs = async function ({ angularVersion, siteRoot, failPlugin }) {
Copy link
Contributor

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?

Copy link
Contributor Author

@pieh pieh Nov 18, 2024

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)

Comment on lines 100 to 104
if (usedEngine === 'CommonEngine') {
await writeFile(serverModuleLocation, NetlifyServerTsCommonEngine)
} else if (usedEngine === 'AppEngine') {
await writeFile(serverModuleLocation, NetlifyServerTsAppEngine)
}
Copy link
Contributor

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?

Copy link
Contributor Author

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

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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

@serhalp serhalp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

@pieh pieh merged commit f6776f5 into main Nov 19, 2024
11 checks passed
@pieh pieh deleted the feat/support-v19 branch November 19, 2024 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: feature code contributing to the implementation of a feature and/or user facing functionality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants