pocketbase / js-sdk

PocketBase JavaScript SDK
https://www.npmjs.com/package/pocketbase
MIT License
2.04k stars 122 forks source link

Access API in (Angular) SPA on same domain outside of routing interception. #155

Closed slimcdk closed 1 year ago

slimcdk commented 1 year ago

Hello folks

I'm really impressed with the quality of this project. I plan on serving Angular and PocketBase on same domain, where PocketBase's API will be accessible on /api however Angular's routing mechanism seem to intercept any calls to this path as navigation and not as calls to my backend server.

I'm initializing PocketBase in an Angular service that I inject throughout my application. So far this works fine as long as PocketBase is served on another domain/port. I suspect I somehow should be able to inject a or wrap the SDK in a HttpClient that Angular will use to make backend calls https://angular.io/guide/http. But how could this be done?

export class PocketBaseService {

  readonly pb: PocketBase;

  constructor() {
    this.pb = new PocketBase(environment.apiHost);
  }
}
ganigeorgiev commented 1 year ago

I'm not sure that I understand the issue and I don't see how it is related to the SDK.

Could you elaborate a little more on your setup? How are you deploying the Angular app? Is there a reverse proxy (eg. nginx) or you are using the pb_public directory to serve static files that comes with PocketBase?

I suspect I somehow should be able to inject a or wrap the SDK in a HttpClient that Angular will use to make backend calls

I don't see why this will be needed. The SDK uses the native fetch for sending requests and cannot be replaced (unless the Angular client has similar APIs).

ganigeorgiev commented 1 year ago

I'm not very up-to-date with the latest Angular development flows and I'm not sure what to recommend, but you may want to check https://angular.io/guide/build#proxying-to-a-backend-server in case it is relevant to your use case.

I'm closing the issue because I don't think is related to the SDK but feel free to provide more details about your setup (or a minimal reproducible repo if possible) and I'll try to investigate it in more details.

slimcdk commented 1 year ago

I know this isn't necessarily an issue with this SDK, but I've been traversing through Angular documentation and Angular forums to find the solution and there seemingly isn't one. I'm not alone about this and I would imagine that existing PB user might have solved it so this is my last bullet in the chamber.

Angular intercepts all requests (for same domain) on the client and nothing is sent to the backend, unless you explicitly use the provided HttpClient. My eager hope was that you could inject your own http client. As long as it implements the needed interface. Very similar to the AuthStorage.

It might as well just be my setup that prevents Angular to forward request from the SDK to the backend instead of the routing mechanism. This is a typical (and minimal) routing configuration with wildcard routing to a 404 page.

const routes: Routes = [
  { path: '', component: HomePageComponent },
  ...
  { path: '**', component: PageNotFoundComponent },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
  providers: []
})

The 'proxy to a backend server' unfortunate only works with the node runtime (primarly during development) and not if you do a production build with a static webserver like nginx, so this option is not available in production. Both PocketBase and Angular are running behind a reverse proxy (traefik) and Angular is being hosted from a nginx instance.

ganigeorgiev commented 1 year ago

Both PocketBase and Angular are running behind a reverse proxy (traefik) and Angular is being hosted from a nginx instance.

My guess is that you are using nginx to rewrite all routes to the static index.html, right? If that's the case, instead of passing down every route to the angular app, you can make an exception and configure the reverse proxy to proxy_pass all /api/* routes to the backend server.

But without an actial traefik/nginx config sample how you are deploying your app, I'm not sure how to help.

If you need an example nginx config for PocketBase you can check https://pocketbase.io/docs/going-to-production/#using-reverse-proxy.

ganigeorgiev commented 1 year ago

Additionally, please note for static client-side apps you don't really need nginx and you can put your files inside pb_public directory next to the executable (you can change the default public dir location with the --publicDir flag).

slimcdk commented 1 year ago

I'm only using nginx as a static webserver. The issue is however that Angular does not send anything to the backend, so I can't even rewrite the routes on my reverse proxy. Requests from the PocketBase SDK gets intercepted by the Angular router, because they aren't performed by the Angular HttpClient.

I'm aware of the pb_public option but this sadly won't change how Angular and the SDK behaves on the client level. I can only imagine that if the SDK used Angulars HttpClient it would work, but I know that this isn't feasble for the SDK to adopt this. My workaround as of now is to simply serve PB's API on a subdomain, but I feel it kind of inflicts with the email linking

Here is my production config that I'm working on:

version: '3.8'

volumes:
  traefik-ssl-certs:
    driver: local

  pb-data:
    driver: local
    driver_opts:
      type: 'none'
      o: 'bind'
      device: '/mnt/volume_fra1_01'

services:

  traefik:
    image: traefik:v2.9
    environment:
      - DO_AUTH_TOKEN=${DO_TRAEFIK_CERT}
    healthcheck:
      test: traefik healthcheck --ping
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 5s
    volumes:
      - traefik-ssl-certs:/ssl-certs
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - 80:80
      - 443:443
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false

      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --entrypoints.web.http.redirections.entryPoint.to=websecure
      - --entrypoints.web.http.redirections.entryPoint.scheme=https

      - --entrypoints.ping.address=:8080
      - --ping.entrypoint=ping

      - --certificatesresolvers.production.acme.dnschallenge=true
      - --certificatesresolvers.production.acme.dnschallenge.provider=digitalocean
      - --certificatesresolvers.production.acme.email=${EMAIL}
      - --certificatesresolvers.production.acme.storage=/ssl-certs/acme.json
      - --certificatesresolvers.production.acme.caserver=https://acme-v02.api.letsencrypt.org/directory

      - --certificatesresolvers.production2.acme.dnschallenge=true
      - --certificatesresolvers.production2.acme.dnschallenge.provider=digitalocean
      - --certificatesresolvers.production2.acme.email=${EMAIL}
      - --certificatesresolvers.production2.acme.storage=/ssl-certs/acme2.json
      - --certificatesresolvers.production2.acme.caserver=https://acme-v02.api.letsencrypt.org/directory
    restart: unless-stopped

  backend:
    image: ghcr.io/slimcdk/<my-angular-nginx>
    volumes:
      - pb-data:/data
    labels:
      - traefik.enable=true
      - traefik.http.routers.backend.entrypoints=websecure
      - traefik.http.routers.backend.tls=true
      - traefik.http.routers.backend.tls.certresolver=production
      - traefik.http.routers.backend.rule=Host(`backend.${DOMAIN}`)
      # - traefik.http.routers.backend.rule=(Host(`${DOMAIN}`) && Path(`/api`))
      - traefik.http.services.backend.loadbalancer.server.port=8090
    command: serve --http=0.0.0.0:8090 --dir=/data --debug=true
    restart: unless-stopped

  frontend:
    image: ghcr.io/slimcdk/<my-pocketbase-as-a-framework>
    labels:
      - traefik.enable=true
      - traefik.http.routers.frontend.entrypoints=websecure
      - traefik.http.routers.frontend.tls=true
      - traefik.http.routers.frontend.tls.certresolver=production
      - traefik.http.routers.frontend.rule=Host(`${DOMAIN}`)
      - traefik.http.services.frontend.loadbalancer.server.port=80
    restart: unless-stopped

  gotenberg:
    image: thecodingmachine/gotenberg:6
    labels:
      - traefik.enable=false
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/ping"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    deploy:
      replicas: 2
    restart: unless-stopped

Dockerfile for the my-angular-nginx image.

FROM node:18 AS deps
WORKDIR /workspace
COPY . .
RUN npm install -g npm@latest @angular/cli@^15
RUN npm install

FROM deps AS build

WORKDIR /workspace

ARG API_DOMAIN=""
ENV NG_APP_API_DOMAIN=$API_DOMAIN
RUN ng build --configuration=production

FROM nginx:stable-alpine
COPY --from=build /workspace/dist/frontend /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

HEALTHCHECK --start-period=5s --interval=10s --timeout=2s --retries=3 CMD curl --fail http://localhost:80/health || exit 1

CMD ["nginx", "-g", "daemon off;"]

Dockerfile for my-pocketbase-as-a-framework image.

# Cache dependencies
FROM golang:1.20 as build-deps
WORKDIR /src
ADD go.mod .
ADD go.sum .
RUN go mod download -x

# Build binary
FROM build-deps as build-env
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -v -o /builds/main /src/server/*

FROM alpine:3.17 as alpine

# Generate TLS certificates
RUN apk add -U --no-cache ca-certificates curl
COPY --from=build-env /builds /usr/local/bin

VOLUME /data

EXPOSE 8090

HEALTHCHECK --start-period=5s --interval=10s --timeout=2s --retries=3 CMD curl --fail http://localhost:8090/api/health || exit 1

ENTRYPOINT [ "/usr/local/bin/main" ]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/data", "--debug=false"]

nginx config

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    #access_log  /var/log/nginx/host.access.log  main;

    root   /usr/share/nginx/html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    location = /health {
        access_log off;
        add_header 'Content-Type' 'application/json';
        return 200 '{"status":"UP"}';
    }
}
slimcdk commented 1 year ago

Apologies for the many edits.

ganigeorgiev commented 1 year ago

Requests from the PocketBase SDK gets intercepted by the Angular router, because they aren't performed by the Angular HttpClient.

But how? I can't find any reference in the Angular documentation for modified/overwritten fetch.

Your setup is too complicated and I'm not sure how to properly reproduce it.

I've tried:

  1. Installed the basic setup from https://angular.io/guide/setup-local
  2. Added a single route with a component that call PocketBase (not as a service but inline in the function, but I doubt that this matter).
  3. Executed npm run build to generate the static files
  4. Moved the static files to a pb_public directory
  5. Started PocketBase and clicked on the button of the component in 2) to test the API call and it worked fine.
slimcdk commented 1 year ago

I found the culprit. It was the developer all along.. I had configured my reverse proxy to redirect on exact /api match and not as a prefix... Everything now works smoothly. Thanks again for this awesome project. And for your time trying to reproduce my issue even though it wasn't remotely related to this SDK.