first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# web-shared-infrastructure
Cliente GraphQL de la web.
- **Import**: `@task-stream/web/shared/infrastructure`
- **Capa**: `infrastructure` · **Tags**: `scope:web-shared`, `type:infrastructure`
## Contenido
- `provideGraphql()`: Apollo con HTTP para queries/mutations y `graphql-ws` para subscriptions; caché desactivada.
- `AccessTokenSource` (puerto): de dónde sale el token (lo implementa identity).
- `API_CONFIG` / `apiConfigFromLocation()`: la API se sirve en el mismo origen (`/api`, `/graphql`).
## Comandos
```sh
npx nx lint web-shared-infrastructure
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,13 @@
{
"name": "web-shared-infrastructure",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/shared/infrastructure/src",
"prefix": "ts",
"projectType": "library",
"tags": ["scope:web-shared", "type:infrastructure"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,4 @@
export * from './lib/api-config';
export * from './lib/access-token-source';
export * from './lib/graphql.provider';
export * from './lib/graphql-errors';

View File

@@ -0,0 +1,10 @@
/**
* Port used by the GraphQL client to authenticate requests. Implemented by
* the identity context (which knows how to refresh and sign out).
*/
export abstract class AccessTokenSource {
/** A valid access token (refreshed if needed), or null when signed out. */
abstract accessToken(): Promise<string | null>;
/** The API rejected the token: the session is over. */
abstract unauthenticated(): void;
}

View File

@@ -0,0 +1,32 @@
import {
InjectionToken,
makeEnvironmentProviders,
type EnvironmentProviders,
} from '@angular/core';
export interface ApiConfig {
/** Base of the REST endpoints, e.g. `/api`. */
restUrl: string;
/** GraphQL over HTTP, e.g. `/graphql`. */
graphqlUrl: string;
/** GraphQL subscriptions (graphql-ws), e.g. `ws://localhost:4200/graphql`. */
graphqlWsUrl: string;
}
export const API_CONFIG = new InjectionToken<ApiConfig>('API_CONFIG');
export function provideApiConfig(config: ApiConfig): EnvironmentProviders {
return makeEnvironmentProviders([{ provide: API_CONFIG, useValue: config }]);
}
/** Relative URLs resolved against the page, so the dev proxy / reverse proxy can serve the API. */
export function apiConfigFromLocation(
location: Location = window.location,
): ApiConfig {
const ws = location.protocol === 'https:' ? 'wss:' : 'ws:';
return {
restUrl: '/api',
graphqlUrl: '/graphql',
graphqlWsUrl: `${ws}//${location.host}/graphql`,
};
}

View File

@@ -0,0 +1,10 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
/** A message fit for a toast from whatever the GraphQL client threw. */
export function errorMessage(error: unknown): string {
if (CombinedGraphQLErrors.is(error)) {
return error.errors.map((e) => e.message).join('\n');
}
if (error instanceof Error) return error.message;
return String(error);
}

View File

@@ -0,0 +1,95 @@
import {
inject,
makeEnvironmentProviders,
type EnvironmentProviders,
} from '@angular/core';
import { ApolloLink, InMemoryCache } from '@apollo/client';
import { SetContextLink } from '@apollo/client/link/context';
import { ErrorLink } from '@apollo/client/link/error';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { provideApollo } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http';
import { createClient } from 'graphql-ws';
import { AccessTokenSource } from './access-token-source';
import { API_CONFIG } from './api-config';
/**
* Apollo client for the task-stream API: HTTP for queries/mutations,
* graphql-ws for subscriptions (token in `connection_init`).
*
* The cache is disabled on purpose: state lives in the NgRx signal stores,
* which are fed by queries and kept fresh by subscriptions.
*/
export function provideGraphql(): EnvironmentProviders {
return makeEnvironmentProviders([
provideApollo(() => {
const config = inject(API_CONFIG);
const tokens = inject(AccessTokenSource);
const httpLink = inject(HttpLink);
const auth = new SetContextLink(async (prevContext) => {
const token = await tokens.accessToken();
return token
? {
headers: {
...prevContext['headers'],
Authorization: `Bearer ${token}`,
},
}
: {};
});
const errors = new ErrorLink(({ error }) => {
if (
CombinedGraphQLErrors.is(error) &&
error.errors.some((e) => e.extensions?.['code'] === 'UNAUTHENTICATED')
) {
tokens.unauthenticated();
}
});
const ws = new GraphQLWsLink(
createClient({
url: config.graphqlWsUrl,
lazy: true,
retryAttempts: Infinity,
shouldRetry: () => true,
connectionParams: async () => {
const token = await tokens.accessToken();
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
);
const http = ApolloLink.from([
auth,
errors,
httpLink.create({ uri: config.graphqlUrl }),
]);
const link = ApolloLink.split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
ApolloLink.from([errors, ws]),
http,
);
return {
link,
cache: new InMemoryCache(),
defaultOptions: {
query: { fetchPolicy: 'no-cache' },
watchQuery: { fetchPolicy: 'no-cache' },
mutate: { fetchPolicy: 'no-cache' },
},
};
}),
]);
}

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}