feat(tenant-service): implement bootstrap request timeout and enhance request context resolution

This commit is contained in:
2026-08-14 15:17:22 -03:00
parent e4670491c7
commit d92b5a7623
11 changed files with 297 additions and 53 deletions

2
.vscode/launch.json vendored
View File

@@ -7,7 +7,7 @@
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: start", "preLaunchTask": "npm: start",
"url": "http://localhost:4200/" "url": "http://localhost:4300/"
}, },
{ {
"name": "ng test", "name": "ng test",

View File

@@ -0,0 +1,28 @@
import { HttpRequest } from '@angular/common/http';
import { isStoreCatalogRequest } from './app.config';
describe('isStoreCatalogRequest', () => {
it.each([
'http://localhost:8000/api/tenants/demo/catalog',
'http://localhost:8000/api/tenants/demo/catalog?currency=ARS',
'http://localhost:8000/api/tenants/demo/catalog/featured-groups/7/items?page=2',
'http://localhost:8000/api/tenants/demo/catalog-items?q=remera&page=1',
'http://localhost:8000/api/tenants/demo/catalog-items/42?variant_id=3',
])('includes GET %s in the hydration transfer cache', (url) => {
expect(isStoreCatalogRequest(new HttpRequest('GET', url))).toBe(true);
});
it.each([
['POST', 'http://localhost:8000/api/tenants/demo/catalog-items'],
['GET', 'http://localhost:8000/api/tenants/demo/productos'],
['GET', 'http://localhost:8000/api/tenants/demo/categories/7'],
['GET', 'http://localhost:8000/api/tenants/demo/catalog-items/42/variant-options'],
['GET', 'http://localhost:8000/api/tenants/demo/catalogue'],
])('excludes %s %s from the hydration transfer cache', (method, url) => {
const request =
method === 'GET' ? new HttpRequest('GET', url) : new HttpRequest('POST', url, null);
expect(isStoreCatalogRequest(request)).toBe(false);
});
});

View File

@@ -13,10 +13,12 @@ import { authInterceptor } from './core/services/auth/auth.interceptor';
import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor'; import { globalLoadingInterceptor } from './core/services/global-loading/global-loading.interceptor';
import { tenantBootstrap } from './core/services/tenant-bootstrap'; import { tenantBootstrap } from './core/services/tenant-bootstrap';
function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean { export function isStoreCatalogRequest(request: HttpRequest<unknown>): boolean {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
/\/api\/tenants\/[^/]+\/productos(?:\/\d+)?(?:\?|$)/.test(request.urlWithParams) /\/api\/tenants\/[^/?#]+\/(?:catalog(?:\/featured-groups\/\d+\/items)?|catalog-items(?:\/\d+)?)\/?(?:[?#]|$)/.test(
request.urlWithParams,
)
); );
} }

View File

@@ -233,6 +233,19 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio'); expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
}); });
it('renders the tenant not found screen at / without redirecting to itself', async () => {
const { fixture, router } = await renderAppAt(
'/',
createTenantServiceStub('not-found', null),
createAuthServiceStub()
);
const compiled = fixture.nativeElement as HTMLElement;
expect(router.url).toBe('/');
expect(compiled.querySelector('.tenant-status')).not.toBeNull();
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('redirects unauthenticated users from /checkout to /login', async () => { it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false)); const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));

View File

@@ -0,0 +1,76 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, UrlTree } from '@angular/router';
import { Tenant } from '../services/tenant.interface';
import { TenantService } from '../services/tenant.service';
import { hasMenuGuard } from './menu.guard';
const tenant: Tenant = {
id: 1,
codigo: 'test',
nombre: 'Test Tenant',
dominio: 'localhost',
primary_color: '#6376F3',
secondary_color: '#A0A0A0',
danger_color: '#FF8888',
success_color: '#198754',
header_bg_color: '#313131',
footer_bg_color: '#313131',
header_logo: 'https://example.com/header.png',
footer_logo: 'https://example.com/footer.png',
categories: [],
menues: [
{
id: 1,
code: 'index',
label: 'Inicio',
parent_menu_code: null,
content_type: 'dynamic',
route: '/',
submenues: [],
},
],
};
function runGuard(menuCode: string, url: string, currentTenant: Tenant | null) {
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{
provide: TenantService,
useValue: { tenant: () => currentTenant },
},
],
});
return TestBed.runInInjectionContext(() =>
hasMenuGuard(menuCode)(null as never, { url } as never),
);
}
describe('hasMenuGuard', () => {
beforeEach(() => {
TestBed.resetTestingModule();
});
it('allows a route exposed by the tenant menu', () => {
expect(runGuard('index', '/', tenant)).toBe(true);
});
it('cancels navigation when the tenant is missing', () => {
expect(runGuard('index', '/', null)).toBe(false);
});
it('redirects a missing menu route to the store root', () => {
const result = runGuard('checkout', '/checkout', tenant);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');
});
it('allows the store root when its menu is missing to avoid a self-redirect', () => {
const tenantWithoutMenus = { ...tenant, menues: [] };
expect(runGuard('index', '/', tenantWithoutMenus)).toBe(true);
});
});

View File

@@ -4,19 +4,19 @@ import { findMenu } from '../services/menu.utils';
import { TenantService } from '../services/tenant.service'; import { TenantService } from '../services/tenant.service';
export const hasMenuGuard = (menuCode: string): CanActivateFn => { export const hasMenuGuard = (menuCode: string): CanActivateFn => {
return () => { return (_route, state) => {
const tenantService = inject(TenantService); const tenantService = inject(TenantService);
const router = inject(Router); const router = inject(Router);
const tenant = tenantService.tenant(); const tenant = tenantService.tenant();
if (!tenant) { if (!tenant) {
return router.createUrlTree(['/']); return false;
} }
const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined; const hasMenu = findMenu(tenant.menues ?? [], menuCode) !== undefined;
if (hasMenu) { if (hasMenu || state.url === '/') {
return true; return true;
} }

View File

@@ -2,8 +2,10 @@ import { PLATFORM_ID, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
@@ -181,6 +183,43 @@ describe('AuthService', () => {
httpController.verify(); httpController.verify();
}); });
it('propagates a /me timeout without clearing the local session', async () => {
cookieStore['shopit.front.auth.token'] = 'valid-token';
vi.useFakeTimers();
try {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
TransferState,
],
});
const service = TestBed.inject(AuthService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
const request = httpController.expectOne(`${environment.url}me`);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(request.cancelled).toBe(true);
expect(service.user()).toBeNull();
expect(service.token()).toBe('valid-token');
expect(cookieStore['shopit.front.auth.token']).toBe('valid-token');
httpController.verify();
} finally {
vi.useRealTimers();
}
});
it('registers without creating a session', () => { it('registers without creating a session', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [

View File

@@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common';
import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { isPlatformBrowser, isPlatformServer } from '@angular/common'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core'; import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs'; import { firstValueFrom, map, Observable, of, tap, timeout } from 'rxjs';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BaseApiService } from '../base-api.service'; import { BaseApiService } from '../base-api.service';
@@ -20,6 +20,7 @@ import {
} from './auth.interfaces'; } from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service'; import { CookieService } from '../cookie/cookie.service';
import { TenantService } from '../tenant.service'; import { TenantService } from '../tenant.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from '../bootstrap-request-timeout';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token'; const AUTH_TOKEN_COOKIE_KEY = 'shopit.front.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user'); const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@@ -33,6 +34,7 @@ export class AuthService extends BaseApiService {
private readonly cookieService = inject(CookieService); private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState); private readonly transferState = inject(TransferState);
private readonly tenantService = inject(TenantService); private readonly tenantService = inject(TenantService);
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly userState = signal<AuthUser | null>(null); private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null); private readonly tokenState = signal<string | null>(null);
@@ -175,7 +177,9 @@ export class AuthService extends BaseApiService {
} }
try { try {
const user = await firstValueFrom(this.loadCurrentUser()); const user = await firstValueFrom(
this.loadCurrentUser().pipe(timeout(this.bootstrapRequestTimeoutMs)),
);
if (isPlatformServer(this.platformId)) { if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user); this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);

View File

@@ -0,0 +1,9 @@
import { InjectionToken } from '@angular/core';
export const BOOTSTRAP_REQUEST_TIMEOUT_MS = new InjectionToken<number>(
'BOOTSTRAP_REQUEST_TIMEOUT_MS',
{
providedIn: 'root',
factory: () => 10_000,
},
);

View File

@@ -2,8 +2,10 @@ import { PLATFORM_ID, REQUEST, RESPONSE_INIT, TransferState } from '@angular/cor
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TimeoutError } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface'; import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store'; import { TENANT_SSR_STATE_KEY } from './tenant-ssr-cache.store';
import { TenantService } from './tenant.service'; import { TenantService } from './tenant.service';
@@ -29,15 +31,15 @@ const tenant: Tenant = {
{ {
id: 2, id: 2,
nombre: 'Manga corta', nombre: 'Manga corta',
subcategories: [] subcategories: [],
} },
] ],
} },
] ],
}; };
const tenantResponse: TenantBootstrapResponse = { const tenantResponse: TenantBootstrapResponse = {
data: tenant data: tenant,
}; };
describe('TenantService', () => { describe('TenantService', () => {
@@ -51,14 +53,16 @@ describe('TenantService', () => {
it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => { it('requests the tenant bootstrap endpoint using the current hostname and stores the response', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService] providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`); const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
expect(request.request.method).toBe('GET'); expect(request.request.method).toBe('GET');
@@ -74,7 +78,7 @@ describe('TenantService', () => {
it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => { it('hydrates the tenant from TransferState without performing HTTP on the browser', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService] providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
@@ -83,7 +87,7 @@ describe('TenantService', () => {
transferState.set(TENANT_SSR_STATE_KEY, { transferState.set(TENANT_SSR_STATE_KEY, {
status: 'ready', status: 'ready',
tenant tenant,
}); });
await expect(service.bootstrap()).resolves.toBeUndefined(); await expect(service.bootstrap()).resolves.toBeUndefined();
@@ -109,12 +113,12 @@ describe('TenantService', () => {
useValue: new Request('https://internal.example/render', { useValue: new Request('https://internal.example/render', {
headers: { headers: {
'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal', 'x-forwarded-host': 'STORE.EXAMPLE.COM:443, proxy.internal',
host: 'ignored.example.com' host: 'ignored.example.com',
} },
}) }),
}, },
{ provide: RESPONSE_INIT, useValue: responseInit } { provide: RESPONSE_INIT, useValue: responseInit },
] ],
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
@@ -122,7 +126,9 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/store.example.com`); const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=store.example.com&path=/render`,
);
request.flush(tenantResponse); request.flush(tenantResponse);
@@ -131,7 +137,7 @@ describe('TenantService', () => {
expect(service.tenant()).toEqual(tenant); expect(service.tenant()).toEqual(tenant);
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({ expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'ready', status: 'ready',
tenant tenant,
}); });
expect(responseInit.status).toBeUndefined(); expect(responseInit.status).toBeUndefined();
@@ -151,12 +157,12 @@ describe('TenantService', () => {
provide: REQUEST, provide: REQUEST,
useValue: new Request('https://internal.example/render', { useValue: new Request('https://internal.example/render', {
headers: { headers: {
host: 'missing.example.com:8443' host: 'missing.example.com:8443',
} },
}) }),
}, },
{ provide: RESPONSE_INIT, useValue: responseInit } { provide: RESPONSE_INIT, useValue: responseInit },
] ],
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
@@ -164,7 +170,9 @@ describe('TenantService', () => {
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/missing.example.com`); const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=missing.example.com&path=/render`,
);
request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' }); request.flush({ message: 'Not Found' }, { status: 404, statusText: 'Not Found' });
@@ -173,7 +181,7 @@ describe('TenantService', () => {
expect(service.tenant()).toBeNull(); expect(service.tenant()).toBeNull();
expect(service.getTenant()).toBeNull(); expect(service.getTenant()).toBeNull();
expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({ expect(transferState.get(TENANT_SSR_STATE_KEY, null)).toEqual({
status: 'not-found' status: 'not-found',
}); });
expect(responseInit.status).toBe(404); expect(responseInit.status).toBe(404);
@@ -182,14 +190,16 @@ describe('TenantService', () => {
it('rejects when the bootstrap response does not contain tenant data', async () => { it('rejects when the bootstrap response does not contain tenant data', async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), TenantService] providers: [provideHttpClient(), provideHttpClientTesting(), TenantService],
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/localhost`); const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
request.flush({}); request.flush({});
@@ -213,19 +223,21 @@ describe('TenantService', () => {
provide: REQUEST, provide: REQUEST,
useValue: new Request('https://internal.example/render', { useValue: new Request('https://internal.example/render', {
headers: { headers: {
host: 'broken.example.com' host: 'broken.example.com',
} },
}) }),
}, },
{ provide: RESPONSE_INIT, useValue: responseInit } { provide: RESPONSE_INIT, useValue: responseInit },
] ],
}); });
const service = TestBed.inject(TenantService); const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController); const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap(); const bootstrapPromise = service.bootstrap();
const request = httpController.expectOne(`${environment.url}tenants/bootstrap/broken.example.com`); const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=broken.example.com&path=/render`,
);
request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' }); request.flush({ message: 'Boom' }, { status: 500, statusText: 'Server Error' });
@@ -235,4 +247,39 @@ describe('TenantService', () => {
httpController.verify(); httpController.verify();
}); });
it('rejects and cancels a tenant bootstrap request that exceeds the configured timeout', async () => {
vi.useFakeTimers();
try {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
TenantService,
{ provide: BOOTSTRAP_REQUEST_TIMEOUT_MS, useValue: 25 },
],
});
const service = TestBed.inject(TenantService);
const httpController = TestBed.inject(HttpTestingController);
const bootstrapPromise = service.bootstrap();
const rejection = expect(bootstrapPromise).rejects.toBeInstanceOf(TimeoutError);
const request = httpController.expectOne(
`${environment.url}tenants/bootstrap?dominio=localhost&path=/`,
);
await vi.advanceTimersByTimeAsync(25);
await rejection;
expect(request.cancelled).toBe(true);
expect(service.status()).toBe('idle');
expect(service.tenant()).toBeNull();
httpController.verify();
} finally {
vi.useRealTimers();
}
});
}); });

View File

@@ -7,22 +7,23 @@ import {
REQUEST, REQUEST,
RESPONSE_INIT, RESPONSE_INIT,
signal, signal,
TransferState TransferState,
} from '@angular/core'; } from '@angular/core';
import { IS_DISCOVERING_ROUTES } from '@angular/ssr'; import { IS_DISCOVERING_ROUTES } from '@angular/ssr';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom, timeout } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { BaseApiService } from './base-api.service'; import { BaseApiService } from './base-api.service';
import { BOOTSTRAP_REQUEST_TIMEOUT_MS } from './bootstrap-request-timeout';
import { Tenant, TenantBootstrapResponse } from './tenant.interface'; import { Tenant, TenantBootstrapResponse } from './tenant.interface';
import { import {
TENANT_SSR_STATE_KEY, TENANT_SSR_STATE_KEY,
TenantBootstrapStatus, TenantBootstrapStatus,
TenantSsrState TenantSsrState,
} from './tenant-ssr-cache.store'; } from './tenant-ssr-cache.store';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class TenantService extends BaseApiService { export class TenantService extends BaseApiService {
private readonly platformId = inject(PLATFORM_ID); private readonly platformId = inject(PLATFORM_ID);
@@ -30,6 +31,7 @@ export class TenantService extends BaseApiService {
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
private readonly transferState = inject(TransferState); private readonly transferState = inject(TransferState);
private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false; private readonly isDiscoveringRoutes = inject(IS_DISCOVERING_ROUTES, { optional: true }) ?? false;
private readonly bootstrapRequestTimeoutMs = inject(BOOTSTRAP_REQUEST_TIMEOUT_MS);
private readonly tenantState = signal<Tenant | null>(null); private readonly tenantState = signal<Tenant | null>(null);
private readonly statusState = signal<TenantBootstrapStatus>('idle'); private readonly statusState = signal<TenantBootstrapStatus>('idle');
@@ -48,7 +50,6 @@ export class TenantService extends BaseApiService {
return `${environment.url}tenants/${tenant.codigo}`; return `${environment.url}tenants/${tenant.codigo}`;
} }
async bootstrap(): Promise<void> { async bootstrap(): Promise<void> {
if (this.statusState() !== 'idle') { if (this.statusState() !== 'idle') {
return; return;
@@ -69,13 +70,18 @@ export class TenantService extends BaseApiService {
return; return;
} }
const domain = this.resolveDomain(); const { domain, path } = this.resolveRequestContext();
try { try {
const response = await firstValueFrom( const response = await firstValueFrom(
this.http.get<TenantBootstrapResponse>( this.http
`${environment.url}tenants/bootstrap/${domain}` .get<TenantBootstrapResponse>(`${environment.url}tenants/bootstrap`, {
) params: {
dominio: domain,
path,
},
})
.pipe(timeout(this.bootstrapRequestTimeoutMs)),
); );
if (!response?.data) { if (!response?.data) {
@@ -85,7 +91,7 @@ export class TenantService extends BaseApiService {
this.setReady(response.data); this.setReady(response.data);
this.persistSsrState({ this.persistSsrState({
status: 'ready', status: 'ready',
tenant: response.data tenant: response.data,
}); });
} catch (error) { } catch (error) {
if (this.isNotFoundError(error)) { if (this.isNotFoundError(error)) {
@@ -103,7 +109,7 @@ export class TenantService extends BaseApiService {
} }
} }
private resolveDomain(): string { private resolveRequestContext(): { domain: string; path: string } {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
const domain = window.location.hostname; const domain = window.location.hostname;
@@ -111,7 +117,10 @@ export class TenantService extends BaseApiService {
throw new Error('Tenant bootstrap could not resolve the current domain.'); throw new Error('Tenant bootstrap could not resolve the current domain.');
} }
return this.normalizeHost(domain); return {
domain: this.normalizeHost(domain),
path: this.normalizePath(window.location.pathname),
};
} }
const requestHost = const requestHost =
@@ -123,7 +132,14 @@ export class TenantService extends BaseApiService {
throw new Error('Tenant bootstrap could not resolve the current domain.'); throw new Error('Tenant bootstrap could not resolve the current domain.');
} }
return this.normalizeHost(requestHost); const forwardedPath =
this.request?.headers.get('x-forwarded-uri') ?? this.request?.headers.get('x-original-uri');
const requestPath = forwardedPath ?? (this.request ? new URL(this.request.url).pathname : '/');
return {
domain: this.normalizeHost(requestHost),
path: this.normalizePath(requestPath),
};
} }
private normalizeHost(host: string): string { private normalizeHost(host: string): string {
@@ -145,6 +161,16 @@ export class TenantService extends BaseApiService {
return normalizedHost.replace(/:\d+$/, ''); return normalizedHost.replace(/:\d+$/, '');
} }
private normalizePath(path: string): string {
const pathname = path.split(/[?#]/, 1)[0]?.trim() ?? '';
if (!pathname || pathname === '/') {
return '/';
}
return `/${pathname.replace(/^\/+|\/+$/g, '')}`;
}
private applyState(state: TenantSsrState): void { private applyState(state: TenantSsrState): void {
if (state.status === 'ready') { if (state.status === 'ready') {
this.setReady(state.tenant); this.setReady(state.tenant);