kamilkisiela / apollo-angular

A fully-featured, production ready caching GraphQL client for Angular and every GraphQL server 🎁
https://apollo-angular.com
MIT License
1.5k stars 309 forks source link

new feature: SchemaLink for Angular Universal #452

Open muradm opened 6 years ago

muradm commented 6 years ago

Server side Angular allows to render on server. If angular ServerAppModule and GraphQL server are running in same process, it could be possible to shortcut GraphQL calls without additional HTTP round-trip, like this:

@NgModule({ /* */ })
export class BrowserAppModule {
  constructor(apollo: Apollo, httpLink: HttpLink) {
    apollo.create({link: httpLink.create({ uri: '/graphql' }), cache: new InMemoryCache()});
  }
}

export const APP_GRAPHQL_SCHEMA = 'APP_GRAPHQL_SCHEMA';
export const APP_GRAPHQL_CONTEXT = 'APP_GRAPHQL_CONTEXT';

@NgModule({ /* */ })
export class ServerAppModule {
  constructor(apollo: Apollo, @Inject(APP_GRAPHQL_SCHEMA) schema: GraphQLSchema, @Inject(APP_GRAPHQL_CONTEXT) context) {
    apollo.create({cache: this.cache, link: new SchemaLink({schema, context})});
  }
}

Then at ServerAppModule bootstrap:

const schema = makeExecutableSchema(....);

server.get('/*', (req: Request, res: Response) => {
   res.render('dist/index.html', {req: req, res: res,
      providers: [
        {provide: APP_GRAPHQL_SCHEMA, useValue: schema},
        {provide: APP_GRAPHQL_CONTEXT, useValue: new RequestContext()}
      ]}, (err, html) => {
        res.status(html ? 200 : 500).send(html || err.message);
      });
});

Then without any change in Angular application we get local calls to GraphQL server.

However this does not work out of the box, because SchemaLink resolves to Promise outside of angular zone, so that rendering ends before GrahpQL execute finished.

In order to do it properly, one has to execute execute call as zone macro task, like explained here. Then you can't use SchemaLink as provided by Apollo.

Suggestion is to provide AngularSchemaLink by apollo-angular for universal server which properly schedules execute calls.

For now, the following works:

// 1. copy ZoneMacroTaskWrapper from angular source to your project so that you can use it.

export class DirectSchemaLinkRequestMacroTask extends ZoneMacroTaskWrapper<Operation, FetchResult> {
  constructor(private schema: GraphQLSchema, private context) { super(); }
  request(op: Operation): Observable<FetchResult> { return this.wrap(op); }
  protected delegate(op: Operation): Observable<FetchResult> {
    return new Observable<FetchResult>(observer => {
      runQuery({schema: this.schema, query: op.query, rootValue: null, context: this.context, variables: op.variables, operationName: op.operationName})
        .then(result => {
          observer.next(result);
          observer.complete();
        })
        .catch(err => {
          observer.error(err);
        })
    });
  }
}

@Injectable()
export class DirectSchemaLink extends ApolloLink {
  constructor(@Inject(APP_GRAPHQL_SCHEMA) private schema: GraphQLSchema, @Inject(APP_GRAPHQL_CONTEXT) private context) {
    super();
  }
  request(operation: Operation, forward?: NextLink): LinkObservable<FetchResult> | null {
    return <any>new DirectSchemaLinkRequestMacroTask(this.schema, this.context).request(operation);
  }
}

Then you can use it in place of SchemaLink:

@NgModule({
  // ...
  providers: [DirectSchemaLink, /* .... */]
})
export class ServerAppModule {
  constructor(apollo: Apollo, directSchemaLink: DirectSchemaLink) {
    apollo.create({cache: this.cache, link: directSchemaLink});
  }
}

Then your ServerAppModule will properly render without doing HTTP calls.

hiepxanh commented 6 years ago

wow, interesting, is that work with websockettoo ? I really don't know much about ZoneMacroTaskWrapper. I have the same problem, that firebase cannot process in universal, because angular rendered before data come back.

can I ask, what is: request(op: Operation): Observable<FetchResult> { return this.wrap(op); } (line 3) is that force to schedule by angular zone ? can you make a simple guide, just very small to help me understand to work with some other libs ? that wil be very useful for me. please please please

kamilkisiela commented 6 years ago

Yes, that's true. Angular bootstraps when it goes stable and it is stable where there are no more Tasks running. I was working hard with Zone.js and its Tasks while fixing SSR support for apollo-angular v0.13 and few versions before. Angular changed something between v4 and v5 and it broke apollo-angular back then.

This is why I was soooo happy when ApolloClient 2.0 introduced ApolloLinks. This way I could use Angular's HttpClient to make requests instead of fetch and we got SSR support without any additional work.

Angular side of Apollo Community could create an ApolloLink that schedules Zone.js's MacroTask and completes it after execution of Links like HttpLink, WebSocketLink etc. This way we could reuse Link in many many cases.

muradm commented 4 years ago

Long time, still no straight forward solution for this issue :)

Here is the standalone gist which includes AngularSchemaLink. May be some one could add another package like apollo-angular-link-schema

Basically it can be used like:

In backend main.ts provide the schema and context values:

// main.ts
app.get('*', (req, res) => {
    res.render(
      'index.html',
      {
        bootstrap: AppServerModuleNgFactory,
        providers: [
          provideModuleMap(LAZY_MODULE_MAP),
          { provide: 'SSR_GRAPHQL_SCHEMA', useValue: context.schema },
          { provide: 'SSR_GRAPHQL_CONTEXT', useValue: context }
        ],
        url: req.url,
        document: fs.readFileSync('./dist/browser/index.html'),
        req,
        res
      }
    )
  })

And in AppServerModule configure Apollo:

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    NoopAnimationsModule,
    FlexLayoutServerModule,
   // ....
    ModuleMapLoaderModule
  ],
  bootstrap: [AppComponent],
  providers: [
    // ...
    {
      provide: APOLLO_OPTIONS,
      useFactory: (schema: GraphQLSchema, context: any) => {
        return {
          cache: new InMemoryCache(),
          link: new AngularSchemaLink({ schema, context })
        }
      },
      deps: ['SSR_GRAPHQL_SCHEMA', 'SSR_GRAPHQL_CONTEXT']
    }
  ]
})
export class AppServerModule {}

TransferState can be used for SSR features of Apollo as well.

@hiepxanh, for line 3, you may look into gist above. As mentioned before it relays on adapted copy paste of ZoneMacroTaskWrapper from @angular/platform-server. Basically utility class to wrap some observable into Zone.js task.

For websockets... What is websocket? Connection established from client browser to server. Whole point of using SchemaLink is to avoid unnecessary round trips when application is being rendered server side. Let's imagine that websocket we have application which uses websocket, how it should suppose to behave when rendered on server side? probably such actions would be guarded with something like isPlatformBrowser(). I.e. just don't make websocket subscriptions when being rendered server side. Websocket is long lived thing, how would one shot render process complete?