├── .gitignore ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── server ├── main.ts └── ngServerServices.ts ├── src ├── @client │ ├── .gitkeep │ └── ExampleService.ts ├── @server │ ├── ExampleService.ts │ └── index.ts ├── @types │ └── global.d.ts ├── app │ ├── about │ │ ├── about.component.css │ │ ├── about.component.html │ │ ├── about.component.spec.ts │ │ └── about.component.ts │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.browser.ts │ ├── app.config.server.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── home │ │ ├── home.component.css │ │ ├── home.component.html │ │ ├── home.component.spec.ts │ │ └── home.component.ts │ └── todos │ │ ├── todos.component.css │ │ ├── todos.component.html │ │ ├── todos.component.spec.ts │ │ ├── todos.component.ts │ │ └── todos.service.ts ├── assets │ └── .gitkeep ├── bootstrap.browser.ts ├── bootstrap.server.ts ├── favicon.ico ├── index.html ├── main.ts └── styles.css ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.server.json ├── tsconfig.spec.json ├── webpack.config.ts └── webpack └── AngularServerServicePlugin.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .angular 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Angular Server Services 🍑 2 | 3 | This project presents an example of Angular Server Services implementation, utilizing proxying for client services to make RPC (Remote Procedure Calls) to the server service. The goal of this example is to demonstrate how easily these services can be auto-generated, potentially reducing half of your existing codebase. 4 | 5 | Whats used in the repo: 6 | * Angular 16 7 | * Standalone Components 8 | * Universal 9 | * Hydration 10 | * NgExpressEngine 11 | * Custom Webpack 12 | 13 | > Please note, we are currently using injection-js to bypass the Angular injector and micotask to force zone.js to wait on the server-side. This workarounds are only needed to workaround how Angular bootstraps and manages the apps on the server. I am also creating my own TransferState service just for this demo. 14 | 15 | ## Why 16 | 17 | The goal is to replicate GraphQL or RSC by moving all domain logic that lives in these services to the server. We want to do this so we can utilize caching on the server and remove all client code for these services (usually half your codebase) 18 | 19 | Angular can easily support this pattern in Angular Universal with little effort. 20 | 21 | ## Start 22 | 23 | ```bash 24 | $ npm install 25 | $ npm run dev:ssr 26 | ``` 27 | go to [localhost ](http://localhost:4200/) 28 | 29 | Screenshot 2023-05-30 at 7 32 35 PM 30 | 31 | 32 | Initial load uses transfer state. When you navigate to another page the back to Home we will make an RPC to get the updated state 33 | 34 | * [/app/home/ServerService/example.service.server.ts](https://github.com/PatrickJS/angular-server-services/blob/main/src/app/home/ServerService/example.service.server.ts): ServerService example 35 | * [/server/main.ts](https://github.com/PatrickJS/angular-server-services/blob/e5deec3011d17c1f7301b848eb3f88d268ea8454/server/main.ts#L36...L45): server RPC endpoint 36 | * [/app/app.config.browser](https://github.com/PatrickJS/angular-server-services/blob/main/src/app/app.config.browser.ts#L10...L38): client RPC requests 37 | 38 | If we had Angular support then the api would look like this (a lot less code) 39 | * [branch for ideal api](https://github.com/PatrickJS/angular-server-services/tree/ideal-api) 40 | * [ExampleService](https://github.com/PatrickJS/angular-server-services/blob/ideal-api/src/%40server/Example.service.ts) 41 | * [HomeComponent](https://github.com/PatrickJS/angular-server-services/blob/ideal-api/src/app/home/home.component.ts#L4) 42 | 43 | Production ready version 44 | * WIP https://github.com/PatrickJS/angular-server-services/pull/2 45 | * Preview 46 | 47 | https://github.com/PatrickJS/angular-server-services/assets/1016365/8b00d775-42c4-4d29-b79a-815906d35d04 48 | 49 | 50 | # TODO: production ready version 51 | - [x] use webpack to auto-generate ServerServices 52 | - [x] create @server folder in src that will be all server services and components 53 | - [x] use angular TransferState 54 | - [x] batch client requests 55 | - [x] batch server requests 56 | - [ ] server commponents 57 | - [ ] hook into router to batch requests for server components 58 | - [ ] mixed server in client components and vice versa 59 | - [ ] server and client caching 60 | - [ ] UI over http 61 | 62 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-server-services": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "standalone": true 11 | }, 12 | "@schematics/angular:directive": { 13 | "standalone": true 14 | }, 15 | "@schematics/angular:pipe": { 16 | "standalone": true 17 | } 18 | }, 19 | "root": "", 20 | "sourceRoot": "src", 21 | "prefix": "app", 22 | "architect": { 23 | "build": { 24 | "builder": "@angular-builders/custom-webpack:browser", 25 | "options": { 26 | "optimization": false, 27 | "customWebpackConfig": { 28 | "path": "./webpack.config.ts" 29 | }, 30 | "outputPath": "dist/angular-server-services/browser", 31 | "index": "src/index.html", 32 | "main": "src/main.ts", 33 | "polyfills": [ 34 | "zone.js" 35 | ], 36 | "tsConfig": "tsconfig.app.json", 37 | "assets": [ 38 | "src/favicon.ico", 39 | "src/assets" 40 | ], 41 | "styles": [ 42 | "src/styles.css" 43 | ], 44 | "scripts": [] 45 | }, 46 | "configurations": { 47 | "production": { 48 | "budgets": [ 49 | { 50 | "type": "initial", 51 | "maximumWarning": "500kb", 52 | "maximumError": "5mb" 53 | }, 54 | { 55 | "type": "anyComponentStyle", 56 | "maximumWarning": "2kb", 57 | "maximumError": "4kb" 58 | } 59 | ], 60 | "outputHashing": "all" 61 | }, 62 | "development": { 63 | "buildOptimizer": false, 64 | "optimization": false, 65 | "vendorChunk": true, 66 | "extractLicenses": false, 67 | "sourceMap": true, 68 | "namedChunks": true 69 | } 70 | }, 71 | "defaultConfiguration": "production" 72 | }, 73 | "serve": { 74 | "builder": "@angular-builders/custom-webpack:dev-server", 75 | "options": { 76 | "customWebpackConfig": { 77 | "path": "./webpack.config.ts" 78 | } 79 | }, 80 | "configurations": { 81 | "production": { 82 | "browserTarget": "angular-server-services:build:production" 83 | }, 84 | "development": { 85 | "browserTarget": "angular-server-services:build:development" 86 | } 87 | }, 88 | "defaultConfiguration": "development" 89 | }, 90 | "extract-i18n": { 91 | "builder": "@angular-devkit/build-angular:extract-i18n", 92 | "options": { 93 | "browserTarget": "angular-server-services:build" 94 | } 95 | }, 96 | "test": { 97 | "builder": "@angular-builders/custom-webpack:karma", 98 | "options": { 99 | "polyfills": [ 100 | "zone.js", 101 | "zone.js/testing" 102 | ], 103 | "tsConfig": "tsconfig.spec.json", 104 | "assets": [ 105 | "src/favicon.ico", 106 | "src/assets" 107 | ], 108 | "styles": [ 109 | "src/styles.css" 110 | ], 111 | "scripts": [] 112 | } 113 | }, 114 | "server": { 115 | "builder": "@angular-builders/custom-webpack:server", 116 | "options": { 117 | "customWebpackConfig": { 118 | "path": "./webpack.config.ts" 119 | }, 120 | "optimization": false, 121 | "outputPath": "dist/angular-server-services/server", 122 | "main": "server/main.ts", 123 | "tsConfig": "tsconfig.server.json" 124 | }, 125 | "configurations": { 126 | "production": { 127 | "outputHashing": "media" 128 | }, 129 | "development": { 130 | "optimization": false, 131 | "sourceMap": true, 132 | "extractLicenses": false, 133 | "vendorChunk": true 134 | } 135 | }, 136 | "defaultConfiguration": "production" 137 | }, 138 | "serve-ssr": { 139 | "builder": "@nguniversal/builders:ssr-dev-server", 140 | "configurations": { 141 | "development": { 142 | "browserTarget": "angular-server-services:build:development", 143 | "serverTarget": "angular-server-services:server:development" 144 | }, 145 | "production": { 146 | "browserTarget": "angular-server-services:build:production", 147 | "serverTarget": "angular-server-services:server:production" 148 | } 149 | }, 150 | "defaultConfiguration": "development" 151 | }, 152 | "prerender": { 153 | "builder": "@nguniversal/builders:prerender", 154 | "options": { 155 | "routes": [ 156 | "/", 157 | "/about", 158 | "/todos" 159 | ] 160 | }, 161 | "configurations": { 162 | "production": { 163 | "browserTarget": "angular-server-services:build:production", 164 | "serverTarget": "angular-server-services:server:production" 165 | }, 166 | "development": { 167 | "browserTarget": "angular-server-services:build:development", 168 | "serverTarget": "angular-server-services:server:development" 169 | } 170 | }, 171 | "defaultConfiguration": "production" 172 | } 173 | } 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-server-services", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test", 10 | "dev:ssr": "ng run angular-server-services:serve-ssr", 11 | "serve:ssr": "node dist/angular-server-services/server/main.js", 12 | "build:ssr": "ng build && ng run angular-server-services:server", 13 | "prerender": "ng run angular-server-services:prerender" 14 | }, 15 | "private": true, 16 | "dependencies": { 17 | "@angular-builders/custom-webpack": "16.0.0-beta.1", 18 | "@angular/animations": "~16.0.3", 19 | "@angular/common": "~16.0.3", 20 | "@angular/compiler": "~16.0.3", 21 | "@angular/core": "~16.0.3", 22 | "@angular/forms": "~16.0.3", 23 | "@angular/platform-browser": "~16.0.3", 24 | "@angular/platform-browser-dynamic": "~16.0.3", 25 | "@angular/platform-server": "~16.0.3", 26 | "@angular/router": "~16.0.3", 27 | "@babel/generator": "7.22.3", 28 | "@babel/parser": "7.22.4", 29 | "@babel/traverse": "7.22.4", 30 | "@babel/types": "7.22.4", 31 | "@nguniversal/express-engine": "^16.0.0-next.0", 32 | "@types/babel-generator": "6.25.5", 33 | "@types/babel-traverse": "6.25.7", 34 | "body-parser": "1.20.2", 35 | "cross-fetch": "3.1.6", 36 | "express": "^4.15.2", 37 | "injection-js": "2.4.0", 38 | "is-browser": "2.1.0", 39 | "reflect-metadata": "0.1.13", 40 | "rxjs": "~7.8.0", 41 | "tslib": "^2.3.0", 42 | "zone.js": "~0.13.0" 43 | }, 44 | "devDependencies": { 45 | "@angular-devkit/build-angular": "^16.0.3", 46 | "@angular/cli": "~16.0.3", 47 | "@angular/compiler-cli": "~16.0.3", 48 | "@nguniversal/builders": "^16.0.0-next.0", 49 | "@types/babel__generator": "7.6.4", 50 | "@types/babel__traverse": "7.20.0", 51 | "@types/express": "^4.17.0", 52 | "@types/jasmine": "~4.3.0", 53 | "@types/node": "^14.15.0", 54 | "@types/prettier": "2.7.2", 55 | "jasmine-core": "~4.6.0", 56 | "karma": "~6.4.0", 57 | "karma-chrome-launcher": "~3.1.0", 58 | "karma-coverage": "~2.2.0", 59 | "karma-jasmine": "~5.1.0", 60 | "karma-jasmine-html-reporter": "~2.0.0", 61 | "prettier": "2.8.8", 62 | "typescript": "~5.0.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /server/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; // injection-js 2 | import 'cross-fetch/polyfill'; 3 | import 'zone.js/node'; 4 | import 'zone.js/dist/zone-patch-fetch'; 5 | 6 | import { APP_BASE_HREF } from '@angular/common'; 7 | import { ngExpressEngine } from '@nguniversal/express-engine'; 8 | import * as express from 'express'; 9 | import * as bodyParser from 'body-parser'; 10 | import { existsSync, promises } from 'node:fs'; 11 | import { join } from 'node:path'; 12 | import bootstrap from '../src/bootstrap.server'; 13 | import * as ServerServices from "@server"; 14 | 15 | import {serverService} from './ngServerServices'; 16 | 17 | // The Express app is exported so that it can be used by serverless Functions. 18 | export function app(): express.Express { 19 | const server = express(); 20 | const distFolder = join(process.cwd(), 'dist/angular-server-services/browser'); 21 | const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index'; 22 | 23 | // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine) 24 | server.engine('html', ngExpressEngine({ 25 | bootstrap 26 | })); 27 | 28 | server.set('view engine', 'html'); 29 | server.set('views', distFolder); 30 | 31 | server.use(bodyParser.json()); 32 | 33 | 34 | // Example Express Rest API endpoints 35 | server.get('/api/**', (req, res) => { 36 | }); 37 | 38 | 39 | server.post('/angular-server-services', async (req, res) => { 40 | const request = req.body; 41 | console.log('angular-server-services request:', request) 42 | 43 | // setup ngApp for server 44 | const document = await promises.readFile(join(distFolder, indexHtml + '.html'), 'utf-8'); 45 | // angular needs to render a url 46 | const url = `${req.protocol}://${req.get('host') || ''}${req.baseUrl}/`; 47 | const providers = [{ provide: APP_BASE_HREF, useValue: req.baseUrl }] 48 | const config = {document, req, res, url, bootstrap, providers}; 49 | 50 | async function invokeService(appRef: any, Service: any, Method: string, args: any) { 51 | const injector = appRef.injector; 52 | const serviceToken = (ServerServices as any)[Service]; 53 | const serviceInstance = injector.get(serviceToken) as any; 54 | const method = serviceInstance[Method]; 55 | const json = await method.apply(serviceInstance, args); 56 | console.log(`angular-server-service invoke: ${Service}.${Method}( ${JSON.stringify(args)} );`, json) 57 | return json; 58 | } 59 | 60 | const services = await serverService(config); 61 | const invokeServices = request.map(async (info: any) => { 62 | const result = await services.invoke( 63 | invokeService, 64 | info.service, 65 | info.method, 66 | info.args 67 | ); 68 | return result; 69 | }) 70 | const allRes = await Promise.allSettled(invokeServices); 71 | await services.destroy(); 72 | const resJson = allRes.map((res: any, index) => { 73 | return { 74 | ...request[index], 75 | ...res 76 | } 77 | }); 78 | 79 | 80 | res.json(resJson); 81 | }); 82 | 83 | // Serve static files from /browser 84 | server.get('*.*', express.static(distFolder, { 85 | maxAge: '0' 86 | })); 87 | 88 | // All regular routes use the Universal engine 89 | server.get('*', (req, res) => { 90 | res.render(indexHtml, { 91 | req, 92 | providers: [ 93 | { provide: APP_BASE_HREF, useValue: req.baseUrl }, 94 | ], 95 | }, (err, html) => { 96 | if (err) { 97 | console.error(err); 98 | res.send(err); 99 | } 100 | console.log('rendering html'); 101 | res.send(html); 102 | }) 103 | }); 104 | 105 | return server; 106 | } 107 | 108 | function run(): void { 109 | const port = process.env['PORT'] || 4000; 110 | 111 | // Start up the Node server 112 | const server = app(); 113 | server.listen(port, () => { 114 | console.log(`Node Express server listening on http://localhost:${port}`); 115 | }); 116 | } 117 | 118 | // Webpack will replace 'require' with '__webpack_require__' 119 | // '__non_webpack_require__' is a proxy to Node 'require' 120 | // The below code is to ensure that the server is run only when not requiring the bundle. 121 | declare const __non_webpack_require__: NodeRequire; 122 | const mainModule = __non_webpack_require__.main; 123 | const moduleFilename = mainModule && mainModule.filename || ''; 124 | if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { 125 | run(); 126 | } 127 | 128 | export * from '../src/bootstrap.server'; 129 | 130 | // fixes prerendering 131 | export default bootstrap; -------------------------------------------------------------------------------- /server/ngServerServices.ts: -------------------------------------------------------------------------------- 1 | import {first} from 'rxjs/operators'; 2 | import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; 3 | import { 4 | platformServer, 5 | INITIAL_CONFIG, 6 | BEFORE_APP_SERIALIZED, 7 | PlatformState, 8 | ɵSERVER_CONTEXT as SERVER_CONTEXT, 9 | } from '@angular/platform-server'; 10 | import { 11 | ApplicationRef, 12 | Renderer2, 13 | ɵannotateForHydration as annotateForHydration, 14 | ɵENABLED_SSR_FEATURES as ENABLED_SSR_FEATURES, 15 | ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED 16 | } from '@angular/core'; 17 | 18 | const DEFAULT_SERVER_CONTEXT = 'other'; 19 | 20 | function sanitizeServerContext(serverContext: string): string { 21 | const context = serverContext.replace(/[^a-zA-Z0-9\-]/g, ''); 22 | return context.length > 0 ? context : DEFAULT_SERVER_CONTEXT; 23 | } 24 | function appendServerContextInfo(applicationRef: ApplicationRef) { 25 | const injector = applicationRef.injector; 26 | let serverContext = sanitizeServerContext(injector.get(SERVER_CONTEXT, DEFAULT_SERVER_CONTEXT)); 27 | const features = injector.get(ENABLED_SSR_FEATURES); 28 | if (features.size > 0) { 29 | // Append features information into the server context value. 30 | serverContext += `|${Array.from(features).join(',')}`; 31 | } 32 | applicationRef.components.forEach(componentRef => { 33 | const renderer = componentRef.injector.get(Renderer2); 34 | const element = componentRef.location.nativeElement; 35 | if (element) { 36 | renderer.setAttribute(element, 'ng-server-context', serverContext); 37 | } 38 | }); 39 | } 40 | function createServerPlatform(options: {document: string; url: string; platformProviders: any[]}) { 41 | const extraProviders = options.platformProviders ?? []; 42 | return platformServer([ 43 | {provide: INITIAL_CONFIG, useValue: {document: options.document, url: options.url}}, 44 | extraProviders 45 | ]); 46 | } 47 | 48 | 49 | 50 | export async function serverService({ 51 | document, 52 | req, 53 | res, 54 | url, 55 | bootstrap, 56 | providers = [], 57 | }: { 58 | document: string; 59 | req: any; 60 | res: any; 61 | url: string; 62 | providers: any[]; 63 | bootstrap: () => Promise; 64 | }) { 65 | 66 | const platformProviders = [ 67 | ...providers, 68 | { 69 | provide: REQUEST, 70 | useValue: req, 71 | }, 72 | { 73 | provide: RESPONSE, 74 | useValue: res, 75 | } 76 | ]; 77 | const platformRef = createServerPlatform({document, url, platformProviders}); 78 | const applicationRef = await bootstrap(); 79 | // const applicationRef = moduleRef.injector.get(ApplicationRef); 80 | // return _render(platformRef, applicationRef); 81 | // const environmentInjector = applicationRef.injector; 82 | 83 | // Block until application is stable. 84 | await applicationRef.isStable.pipe((first((isStable: boolean) => isStable))).toPromise(); 85 | 86 | // const platformState = platformRef.injector.get(PlatformState); 87 | // if (applicationRef.injector.get(IS_HYDRATION_DOM_REUSE_ENABLED, false)) { 88 | // annotateForHydration(applicationRef, platformState.getDocument()); 89 | // } 90 | 91 | // Run any BEFORE_APP_SERIALIZED callbacks just before rendering to string. 92 | // const callbacks = environmentInjector.get(BEFORE_APP_SERIALIZED, null); 93 | // if (callbacks) { 94 | // const asyncCallbacks: Promise[] = []; 95 | // for (const callback of callbacks) { 96 | // try { 97 | // const callbackResult = callback(); 98 | // if (callbackResult) { 99 | // asyncCallbacks.push(callbackResult); 100 | // } 101 | // } catch (e) { 102 | // // Ignore exceptions. 103 | // console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', e); 104 | // } 105 | // } 106 | 107 | // if (asyncCallbacks.length) { 108 | // for (const result of await Promise.allSettled(asyncCallbacks)) { 109 | // if (result.status === 'rejected') { 110 | // console.warn('Ignoring BEFORE_APP_SERIALIZED Exception: ', result.reason); 111 | // } 112 | // } 113 | // } 114 | // } 115 | 116 | // appendServerContextInfo(applicationRef); 117 | // const output = platformState.renderToString(); 118 | return { 119 | invoke(cb: any, Service: any, Method: string, args: any): Promise { 120 | return cb(applicationRef, Service, Method, args); 121 | }, 122 | destroy() { 123 | return new Promise((resolve) => { 124 | setTimeout(() => { 125 | platformRef.destroy(); 126 | resolve(); 127 | }, 0); 128 | }); 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /src/@client/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatrickJS/angular-server-services/7a5cba314256915672f386239e094d683bb13622/src/@client/.gitkeep -------------------------------------------------------------------------------- /src/@client/ExampleService.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core" 2 | 3 | @Injectable({ 4 | providedIn: "root", 5 | }) 6 | export class ExampleService { 7 | async getTodo(options: { id: number }): Promise {} 8 | } 9 | -------------------------------------------------------------------------------- /src/@server/ExampleService.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from "@angular/common/http"; 2 | import { Injectable, TransferState, inject, makeStateKey } from "@angular/core" 3 | import { lastValueFrom } from "rxjs"; 4 | 5 | @Injectable({ 6 | providedIn: "root", 7 | }) 8 | export class ExampleService { 9 | private httpClient = inject(HttpClient); 10 | private transferState = inject(TransferState); 11 | async getTodo(options: { id: number }): Promise { 12 | 13 | // TODO: zone.js not capturing async/await 14 | const macroTask = Zone.current 15 | .scheduleMacroTask( 16 | `WAITFOR-${Math.random()}-${Date.now()}`, 17 | () => {}, 18 | {}, 19 | () => {} 20 | ); 21 | 22 | const id = options.id 23 | const key = makeStateKey(`ExampleService.getTodo(${JSON.stringify(Array.from(arguments))})`); 24 | 25 | // toPromise() with lastValueFrom() so I can use await 26 | const data = await lastValueFrom( 27 | this.httpClient.get(`https://jsonplaceholder.typicode.com/todos/${id}`) 28 | ); 29 | const json = JSON.stringify(data, null, 2); 30 | console.log('http response: ', json); 31 | 32 | this.transferState.set(key, data); 33 | 34 | // TODO: zone.js not capturing async/await 35 | macroTask.invoke(); 36 | return data; 37 | } 38 | } -------------------------------------------------------------------------------- /src/@server/index.ts: -------------------------------------------------------------------------------- 1 | export { ExampleService } from "./ExampleService"; -------------------------------------------------------------------------------- /src/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js/zone.d.ts'; 2 | 3 | // ignore this typescript thing 4 | export {}; 5 | 6 | // declare globals 7 | declare global { 8 | const APP_VERSION: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/app/about/about.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatrickJS/angular-server-services/7a5cba314256915672f386239e094d683bb13622/src/app/about/about.component.css -------------------------------------------------------------------------------- /src/app/about/about.component.html: -------------------------------------------------------------------------------- 1 |

about works!

2 | 3 | -------------------------------------------------------------------------------- /src/app/about/about.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { AboutComponent } from './about.component'; 4 | 5 | describe('AboutComponent', () => { 6 | let component: AboutComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [AboutComponent] 12 | }); 13 | fixture = TestBed.createComponent(AboutComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | @Component({ 5 | selector: 'app-about', 6 | standalone: true, 7 | imports: [CommonModule], 8 | templateUrl: './about.component.html', 9 | styleUrls: ['./about.component.css'] 10 | }) 11 | export default class AboutComponent { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatrickJS/angular-server-services/7a5cba314256915672f386239e094d683bb13622/src/app/app.component.css -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | Home | 2 | About | 3 | Todos 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(() => TestBed.configureTestingModule({ 6 | imports: [AppComponent] 7 | })); 8 | 9 | it('should create the app', () => { 10 | const fixture = TestBed.createComponent(AppComponent); 11 | const app = fixture.componentInstance; 12 | expect(app).toBeTruthy(); 13 | }); 14 | 15 | it(`should have the 'angular-universal-standalone' title`, () => { 16 | const fixture = TestBed.createComponent(AppComponent); 17 | const app = fixture.componentInstance; 18 | expect(app.title).toEqual('angular-universal-standalone'); 19 | }); 20 | 21 | it('should render title', () => { 22 | const fixture = TestBed.createComponent(AppComponent); 23 | fixture.detectChanges(); 24 | const compiled = fixture.nativeElement as HTMLElement; 25 | expect(compiled.querySelector('.content span')?.textContent).toContain('angular-universal-standalone app is running!'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { RouterLink, RouterOutlet } from '@angular/router'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | standalone: true, 8 | imports: [ 9 | CommonModule, 10 | RouterOutlet, 11 | RouterLink 12 | ], 13 | templateUrl: './app.component.html', 14 | styleUrls: ['./app.component.css'] 15 | }) 16 | export class AppComponent { 17 | title = 'angular-universal-standalone'; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/app.config.browser.ts: -------------------------------------------------------------------------------- 1 | import { mergeApplicationConfig, ApplicationConfig, TransferState, makeStateKey, APP_INITIALIZER, Injectable, inject } from '@angular/core'; 2 | import { appConfig } from './app.config'; 3 | 4 | import { ExampleService } from "@client/ExampleService"; 5 | import { HttpClient } from '@angular/common/http'; 6 | import { lastValueFrom } from "rxjs"; 7 | 8 | // TODO: auto generate this 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class BatchClientRequests { 14 | httpClient = inject(HttpClient); 15 | transferState = inject(TransferState); 16 | _queue: any[] = []; 17 | _promises = new Map(); 18 | _timer: any = null; 19 | _processing = false; 20 | _delay = 50; 21 | 22 | init(delay: number = this._delay) { 23 | this._delay = delay; 24 | this.queue(); 25 | } 26 | queue() { 27 | if (this._processing) return; 28 | this._processing = true; 29 | this._timer = setTimeout(() => { 30 | this.flush(); 31 | }, this._delay); 32 | } 33 | async flush() { 34 | const body = this._queue; 35 | if (this._queue.length === 0) { 36 | this._processing = false; 37 | clearTimeout(this._timer); 38 | return; 39 | } 40 | this._queue = []; 41 | clearTimeout(this._timer); 42 | const ngServerService = `angular-server-services`; 43 | // TODO: support GET and use query params maybe 44 | const response = await lastValueFrom( 45 | this.httpClient.post(`/${ngServerService}`, body) 46 | ) as typeof body; 47 | let data = null; 48 | 49 | // TODO: use rxjs to ensure backpressure 50 | body.forEach((res: any, index) => { 51 | this._promises.get(res).resolve(response[index].value); 52 | }); 53 | this._processing = false; 54 | if (this._queue.length) { 55 | this.queue(); 56 | } 57 | return data; 58 | } 59 | _createDefer() { 60 | let resolve: any; 61 | let reject: any; 62 | const promise = new Promise((res, rej) => { 63 | resolve = res; 64 | reject = rej; 65 | }); 66 | return { 67 | promise, 68 | resolve, 69 | reject 70 | }; 71 | } 72 | pushQueue(data: any) { 73 | this._queue.push(data); 74 | // create defer promise 75 | const defer = this._createDefer(); 76 | this._promises.set(data, defer); 77 | this.queue(); 78 | return defer.promise; 79 | } 80 | createProxy(service: string) { 81 | // Proxy with httpClient api 82 | // batch requests with rxjs 83 | return new Proxy({}, { 84 | get: (target, method: string) => { 85 | return async (...args: any[]) => { 86 | // does httpClient deterministically stringify args?? 87 | const params = JSON.stringify(args); 88 | const key = makeStateKey(`${service}.${method}(${params})`) 89 | if (this.transferState.hasKey(key)) { 90 | const res = this.transferState.get(key, null); 91 | if (res) { 92 | this.transferState.remove(key); 93 | return res; 94 | } 95 | } 96 | const req = { 97 | service: service, 98 | method: method, 99 | args: args 100 | }; 101 | return this.pushQueue(req) 102 | }; 103 | } 104 | }); 105 | } 106 | 107 | } 108 | 109 | export const browserConfig: ApplicationConfig = { 110 | providers: [ 111 | // TODO: auto generate this 112 | { provide: BatchClientRequests, useClass: BatchClientRequests, }, 113 | { 114 | provide: APP_INITIALIZER, 115 | useFactory: (batch: BatchClientRequests) => { 116 | return () => batch.init(); 117 | }, 118 | deps: [BatchClientRequests], 119 | multi: true, 120 | }, 121 | { 122 | provide: ExampleService, 123 | useFactory: (batch: BatchClientRequests) => batch.createProxy('ExampleService'), 124 | deps: [BatchClientRequests] 125 | }] 126 | }; 127 | export const config = mergeApplicationConfig(appConfig, browserConfig); -------------------------------------------------------------------------------- /src/app/app.config.server.ts: -------------------------------------------------------------------------------- 1 | import { mergeApplicationConfig, ApplicationConfig } from "@angular/core" 2 | import { provideServerRendering } from "@angular/platform-server" 3 | 4 | import { ExampleService } from "@client/ExampleService" 5 | import { ExampleService as ExampleServiceServer } from "@server/ExampleService" 6 | 7 | import { appConfig } from "./app.config" 8 | 9 | const serverConfig: ApplicationConfig = { 10 | providers: [ 11 | provideServerRendering(), 12 | // TODO: auto generate @server/ services 13 | { 14 | provide: ExampleServiceServer, 15 | useClass: ExampleServiceServer, 16 | }, 17 | { 18 | provide: ExampleService, 19 | useExisting: ExampleServiceServer, 20 | }, 21 | ], 22 | } 23 | 24 | export const config = mergeApplicationConfig(appConfig, serverConfig) 25 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { provideHttpClient } from '@angular/common/http'; 2 | import { ApplicationConfig } from '@angular/core'; 3 | import { provideClientHydration } from '@angular/platform-browser'; 4 | import { provideRouter } from '@angular/router'; 5 | 6 | import { routes } from './app.routes'; 7 | 8 | export const appConfig: ApplicationConfig = { 9 | providers: [ 10 | provideRouter(routes), 11 | provideHttpClient(), 12 | provideClientHydration(), 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = [ 4 | { 5 | path: '', loadComponent: () => import('./home/home.component') 6 | }, 7 | { 8 | path: 'about', loadComponent: () => import('./about/about.component') 9 | }, 10 | { 11 | path: 'todos', loadComponent: () => import('./todos/todos.component') 12 | }, 13 | ]; 14 | -------------------------------------------------------------------------------- /src/app/home/home.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatrickJS/angular-server-services/7a5cba314256915672f386239e094d683bb13622/src/app/home/home.component.css -------------------------------------------------------------------------------- /src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 |

home works!

2 | APP_VERSION: {{ APP_VERSION }} 3 | 4 |
{{ example | async | json }}
5 |
{{ example1 | async | json }}
6 |
{{ example2 | async | json }}
7 |
{{ example3 | async | json }}
8 |
{{ example4 | async | json }}
9 | -------------------------------------------------------------------------------- /src/app/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { HomeComponent } from './home.component'; 4 | 5 | describe('HomeComponent', () => { 6 | let component: HomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [HomeComponent] 12 | }); 13 | fixture = TestBed.createComponent(HomeComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | import { ExampleService } from "@client/ExampleService"; 5 | 6 | 7 | export function randomNumber(min: number, max: number) { 8 | return Math.floor(Math.random() * (max - min + 1)) + min; 9 | } 10 | 11 | @Component({ 12 | selector: 'app-home', 13 | standalone: true, 14 | imports: [CommonModule], 15 | templateUrl: './home.component.html', 16 | styleUrls: ['./home.component.css'] 17 | }) 18 | export default class HomeComponent { 19 | exampleService = inject(ExampleService); 20 | // request data stream from service 21 | example = this.exampleService.getTodo({ 22 | id: 1 23 | }); 24 | example1 = this.exampleService.getTodo({ 25 | id: randomNumber(1, 5) 26 | }); 27 | example2 = this.exampleService.getTodo({ 28 | id: randomNumber(5, 10) 29 | }); 30 | example3 = this.exampleService.getTodo({ 31 | id: randomNumber(10, 15) 32 | }); 33 | example4 = this.exampleService.getTodo({ 34 | id: randomNumber(15, 20) 35 | }); 36 | 37 | // defined in webpack 38 | // @ts-ignore 39 | APP_VERSION = APP_VERSION; 40 | } -------------------------------------------------------------------------------- /src/app/todos/todos.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatrickJS/angular-server-services/7a5cba314256915672f386239e094d683bb13622/src/app/todos/todos.component.css -------------------------------------------------------------------------------- /src/app/todos/todos.component.html: -------------------------------------------------------------------------------- 1 |

Todos

2 | 3 |
    4 |
  • 5 | 6 | {{todo.title}} 7 | 8 | 14 |
15 | -------------------------------------------------------------------------------- /src/app/todos/todos.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TodosComponent } from './todos.component'; 4 | 5 | describe('TodosComponent', () => { 6 | let component: TodosComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [TodosComponent] 12 | }); 13 | fixture = TestBed.createComponent(TodosComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/todos/todos.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, signal } from '@angular/core'; 2 | import { NgFor } from '@angular/common'; 3 | 4 | import { TodosService } from './todos.service'; 5 | 6 | @Component({ 7 | selector: 'app-todos', 8 | standalone: true, 9 | imports: [NgFor], 10 | templateUrl: './todos.component.html', 11 | styleUrls: ['./todos.component.css'] 12 | }) 13 | export default class TodosComponent { 14 | todos = signal([]); 15 | todosService = inject(TodosService); 16 | 17 | ngOnInit() { 18 | this.todosService.getTodos().then(todos => { 19 | console.log('todos', todos.length); 20 | this.todos.set(todos); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/todos/todos.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from "@angular/common/http"; 2 | import { Injectable, inject } from "@angular/core"; 3 | import { firstValueFrom } from "rxjs"; 4 | 5 | @Injectable({ 6 | providedIn: "root" 7 | }) 8 | export class TodosService { 9 | http = inject(HttpClient); 10 | 11 | getTodos() { 12 | return firstValueFrom(this.http.get("https://jsonplaceholder.typicode.com/todos")); 13 | } 14 | } -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PatrickJS/angular-server-services/7a5cba314256915672f386239e094d683bb13622/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/bootstrap.browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { bootstrapApplication } from '@angular/platform-browser'; 10 | import { AppComponent } from './app/app.component'; 11 | import { config } from './app/app.config.browser'; 12 | 13 | const bootstrap = () => bootstrapApplication(AppComponent, config).catch((err) => console.error(err)); 14 | 15 | export default bootstrap; 16 | -------------------------------------------------------------------------------- /src/bootstrap.server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google LLC All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | import { bootstrapApplication } from '@angular/platform-browser'; 10 | import { AppComponent } from './app/app.component'; 11 | import { config } from './app/app.config.server'; 12 | 13 | const bootstrap = () => bootstrapApplication(AppComponent, config); 14 | 15 | export default bootstrap; 16 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- 1 | �PNG 2 |  3 | IHDR?�~� pHYs  ��~�fIDATH��WKLQ���̔�uG�� e�n�. 6qcb�l?���D`�F#� Ku�F 1Qc� 4 | ��!���� ��C�P�|B?$���ܱ3����I&}��}�̽s�[*�ɀU�A��K��yx�gY�Ajq��3L Š���˫�OD�4��3Ϗ:X�3��o�P J�ğo#IH�a����,{>1/�2$�R AR]�)w��?�sZw^��q�Y�m_��e���r[8�^� 5 | �&p��-���A}c��- ������!����2_) E�)㊪j���v�m��ZOi� g�nW�{�n.|�e2�a&�0aŸ����be�̀��C�fˤE%-{�ֺ��׮C��N��jXi�~c�C,t��T�����r�{� �L)s��V��6%�(�#ᤙ!�]��H�ҐH$R���^R�A�61(?Y舚�>���(Z����Qm�L2�K�ZIc�� 6 | ���̧�C��2!⅄�(����"�Go��>�q��=��$%�z`ѯ��T�&����PHh�Z!=���z��O��������,*VVV�1�f*CJ�]EE��K�k��d�#5���`2yT!�}7���߈~�,���zs�����y�T��V������D��C2�G��@%̑72Y�޾{oJ�"@��^h�~ ��fĬ�!a�D��6���Ha|�3��� [>�����]7U2п���]�ė 7 | ��PU� �.Wejq�in�g��+p<ߺQH����總j[������.��� Q���p _�K�� 1(��+��bB8�\ra 8 | �́�v.l���(���ǽ�w���L��w�8�C��IEND�B`� -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Angular Server Services 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import bootstrap from './bootstrap.browser'; 2 | 3 | // Dom ready 4 | requestAnimationFrame(function ready () { 5 | return document.body ? bootstrap() : requestAnimationFrame(ready); 6 | }); 7 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "resolveJsonModule": true, 6 | "baseUrl": "./", 7 | "outDir": "./dist/out-tsc", 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "downlevelIteration": true, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "node", 19 | "importHelpers": true, 20 | "target": "ES2022", 21 | "module": "ES2022", 22 | "useDefineForClassFields": false, 23 | "lib": [ 24 | "ES2022", 25 | "dom" 26 | ], 27 | "paths": { 28 | "@server": ["src/@server/index"], 29 | "@server/*": ["src/@server/*"], 30 | "@client/*": ["src/@client/*"] 31 | } 32 | }, 33 | "angularCompilerOptions": { 34 | "enableI18nLegacyMessageIdFormat": false, 35 | "strictInjectionParameters": true, 36 | "strictInputAccessModifiers": true, 37 | "strictTemplates": true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.app.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/server", 6 | "types": [ 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/bootstrap.server.ts", 12 | "server/main.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, DefinePlugin } from 'webpack'; 2 | import { CustomWebpackBrowserSchema, TargetOptions } from '@angular-builders/custom-webpack'; 3 | import * as path from 'path'; 4 | 5 | import * as pkg from './package.json'; 6 | import { AngularServerServicePlugin } from './webpack/AngularServerServicePlugin'; 7 | 8 | 9 | export default ( 10 | cfg: Configuration, 11 | opts: CustomWebpackBrowserSchema, 12 | targetOptions: TargetOptions 13 | ) => { 14 | const isServer = targetOptions.target === 'server'; 15 | cfg?.plugins?.push( 16 | new DefinePlugin({ 17 | APP_VERSION: JSON.stringify(pkg.version), 18 | }), 19 | ); 20 | // if (!isServer) { 21 | // cfg?.plugins?.push( 22 | // new AngularServerServicePlugin({ 23 | // "target": isServer ? 'server' : 'browser', 24 | // // TODO: grab server config from angular.json 25 | // "serverConfig": path.join(__dirname, 'src/app/app.config.server.ts'), 26 | // // TODO: grab all components in @server folder 27 | // "serverComponents": [ 28 | // "ExampleService" 29 | // ] 30 | // }) 31 | // ); 32 | // } 33 | 34 | return cfg; 35 | }; -------------------------------------------------------------------------------- /webpack/AngularServerServicePlugin.ts: -------------------------------------------------------------------------------- 1 | import * as prettier from 'prettier'; 2 | import { parse, ParserOptions } from '@babel/parser'; 3 | import traverse, { NodePath } from '@babel/traverse'; 4 | import * as t from '@babel/types'; 5 | import generate from '@babel/generator'; 6 | import * as fs from 'fs'; 7 | import * as path from 'path'; 8 | 9 | function getFilesInDirectory(directory: string, extension: string): string[] { 10 | let files: string[] = []; 11 | 12 | const items = fs.readdirSync(directory); 13 | for (const item of items) { 14 | const fullPath = path.join(directory, item); 15 | const stat = fs.statSync(fullPath); 16 | 17 | if (stat.isDirectory()) { 18 | files = files.concat(getFilesInDirectory(fullPath, extension)); 19 | } else if (path.extname(item) === extension) { 20 | files.push(fullPath); 21 | } 22 | } 23 | 24 | return files; 25 | } 26 | 27 | 28 | interface Compiler { 29 | inputFileSystem: any; 30 | outputFileSystem: any; 31 | context: string; 32 | hooks: { 33 | beforeCompile: { 34 | tapAsync: (name: string, callback: (params: unknown, cb: () => void) => void) => void; 35 | }; 36 | }; 37 | } 38 | 39 | export class AngularServerServicePlugin { 40 | once = false; 41 | constructor(private options: { target: 'server' | 'browser', serverConfig: string, serverComponents: string[] }) {} 42 | apply(compiler: Compiler) { 43 | if (this.once) { 44 | return; 45 | } 46 | compiler.hooks.beforeCompile.tapAsync('AngularServerServicePlugin', (params, callback) => { 47 | if (this.once) { 48 | callback(); 49 | return; 50 | } 51 | const serverComponents = this.options.serverComponents; 52 | const parserOptions: ParserOptions = { 53 | sourceType: 'module', 54 | plugins: ['typescript', 'decorators-legacy'], 55 | }; 56 | this.generateClientComponent(serverComponents, compiler, parserOptions); 57 | this.replaceServerWithClientImports( 58 | path.resolve(compiler.context, './src/app'), 59 | compiler, 60 | parserOptions 61 | ); 62 | this.generateServerConfig(serverComponents, compiler, parserOptions); 63 | 64 | 65 | this.once = true; 66 | callback(); 67 | }); 68 | } 69 | 70 | generateServerConfig(serverComponents: string[], compiler: Compiler, parserOptions: ParserOptions) { 71 | const filePath = path.resolve(compiler.context, this.options.serverConfig); 72 | const fileContent = fs.readFileSync(filePath, 'utf-8'); 73 | const ast = parse(fileContent, parserOptions); 74 | // console.log('ast', ast); 75 | 76 | traverse(ast, { 77 | Program(path: NodePath) { 78 | const newImportStatements = [ 79 | "import { ReflectiveInjector } from 'injection-js';", 80 | "import { ExampleService } from '@client/ExampleService';", 81 | "import { ExampleService as ExampleServiceServer } from '@server/ExampleService';", 82 | ].map((statement) => { 83 | const ast = parse(statement, parserOptions); 84 | // console.log('ast', ast); 85 | return ast.program.body[0] as t.ImportDeclaration; 86 | }); 87 | 88 | path.node.body.unshift(...newImportStatements); 89 | 90 | const newVariableStatements = [ 91 | `export const injector = ReflectiveInjector.resolveAndCreate([ 92 | { provide: ExampleServiceServer, useClass: ExampleServiceServer }, 93 | { provide: ExampleService, useExisting: ExampleServiceServer }, 94 | { provide: 'ExampleService', useExisting: ExampleServiceServer } 95 | ]);`, 96 | ].map((statement) => { 97 | const ast = parse(statement, parserOptions); 98 | // console.log('ast', ast); 99 | return ast.program.body[0] as t.ExportNamedDeclaration; 100 | }); 101 | 102 | path.node.body.push(...newVariableStatements); 103 | }, 104 | 105 | VariableDeclaration(path: NodePath) { 106 | if ( 107 | t.isIdentifier(path.node.declarations[0].id) && 108 | path.node.declarations[0].id.name === 'serverConfig' && 109 | t.isObjectExpression(path.node.declarations[0].init!) 110 | ) { 111 | const providersProperty = path.node.declarations[0].init!.properties.find( 112 | (property): property is t.ObjectProperty => 113 | t.isObjectProperty(property) && 114 | t.isIdentifier(property.key) && 115 | property.key.name === 'providers' 116 | ); 117 | 118 | if ( 119 | providersProperty && 120 | t.isArrayExpression(providersProperty.value) 121 | ) { 122 | // Iterate over each component in the serverComponents array 123 | for (const Service of serverComponents) { 124 | // Generate the provider string and parse it 125 | const provider = `{ provide: ${Service}, useFactory: () => injector.get(${Service}) }`; 126 | const newProvider = parse( 127 | `(${ provider })`, 128 | parserOptions 129 | ).program.body[0] as t.ExpressionStatement; 130 | // Add the generated provider to the providers array in the AST 131 | providersProperty.value.elements.push(newProvider.expression as t.ObjectExpression); 132 | } 133 | } 134 | } 135 | }, 136 | }); 137 | 138 | const output = generate(ast, {}, fileContent); 139 | const formattedOutput = prettier.format(output.code, { semi: false, parser: "babel" }); 140 | // fs.writeFileSync(filePath, formattedOutput, 'utf-8'); 141 | // const fileContent = compiler.inputFileSystem.readFileSync(filePath, 'utf-8'); 142 | compiler.outputFileSystem.writeFileSync(filePath, formattedOutput, 'utf-8'); 143 | 144 | } 145 | replaceServerWithClientImports( 146 | appFolderPath: string, 147 | compiler: Compiler, 148 | parserOptions: ParserOptions 149 | ) { 150 | // Get all .ts files in the app folder and subfolders 151 | const files = getFilesInDirectory(appFolderPath, '.ts'); 152 | 153 | files.forEach((file) => { 154 | const fileContent = fs.readFileSync(file, 'utf-8'); 155 | 156 | // Only edit files with @server and not .server files 157 | if (!fileContent.includes('@server') || file.includes('.server')) { 158 | return; 159 | } 160 | 161 | const ast = parse(fileContent, parserOptions); 162 | 163 | traverse(ast, { 164 | ImportDeclaration(path: NodePath) { 165 | if (t.isStringLiteral(path.node.source) && path.node.source.value.startsWith('@server/')) { 166 | // Replace '@server' with '@client' 167 | path.node.source.value = path.node.source.value.replace('@server', '@client'); 168 | } 169 | }, 170 | }); 171 | 172 | const output = generate(ast, {}, fileContent); 173 | // fs.writeFileSync(file, output.code, 'utf-8'); 174 | compiler.outputFileSystem.writeFileSync(file, output.code, 'utf-8'); 175 | }); 176 | } 177 | 178 | generateClientComponent(serverComponents: string[], compiler: Compiler, parserOptions: ParserOptions) { 179 | const serverServicePath = path.resolve(compiler.context, './src/@server/ExampleService.ts'); 180 | const clientServicePath = path.resolve(compiler.context, './src/@client/ExampleService.ts'); 181 | 182 | const serverServiceContent = fs.readFileSync(serverServicePath, 'utf-8'); 183 | const ast = parse(serverServiceContent, parserOptions); 184 | 185 | traverse(ast, { 186 | ClassDeclaration(path: NodePath) { 187 | const injectableImport = parse( 188 | "import { Injectable } from '@angular/core';", 189 | parserOptions 190 | ).program.body[0] as t.ImportDeclaration; 191 | 192 | const injectableDecorator = t.decorator( 193 | t.callExpression( 194 | t.identifier("Injectable"), 195 | [t.objectExpression([t.objectProperty(t.identifier("providedIn"), t.stringLiteral("root"))])] 196 | ) 197 | ); 198 | 199 | 200 | path.node.decorators = [injectableDecorator]; 201 | 202 | path.node.body.body = path.node.body.body.map((methodDefinition) => { 203 | if (t.isClassMethod(methodDefinition)) { 204 | methodDefinition.body.body = []; 205 | } 206 | return methodDefinition; 207 | }); 208 | 209 | ast.program.body.unshift(injectableImport); 210 | } 211 | }); 212 | 213 | const output = generate(ast, {}, serverServiceContent); 214 | const formattedOutput = prettier.format(output.code, { semi: false, parser: "babel" }); 215 | // fs.writeFileSync(clientServicePath, formattedOutput, 'utf-8'); 216 | compiler.outputFileSystem.writeFileSync(clientServicePath, formattedOutput, 'utf-8'); 217 | } 218 | } --------------------------------------------------------------------------------