Realtime (Next.js) Frontend¶
frontend/realtime is the modern, customer-facing Realtime web application (internally "RT3").
It is a Next.js 15 app (App Router) written in TypeScript and is the primary UI for end users
(shipments, orders, bookings, quotations, insights, inbox, reports, map, admin). In the Aspire model
it is registered as the realtime service.
It is a fully decoupled SPA-style client: it does not render server-side ABP MVC/Razor pages and
it shares no .NET code with the backend. It talks to Cargonerds.HttpApi.Host purely over HTTP
(REST + OData), SignalR, and Server-Sent Events (SSE), and authenticates against
Cargonerds.AuthServer over OpenID Connect.
The legacy Blazor admin UI remains the internal/admin console; the public marketing site is Web.Public. For how the AppHost discovers and runs this app, see Aspire integration.
Workspace layout
frontend/ is an npm workspaces monorepo with exactly two members declared in
frontend/package.json: realtime (this app) and ui-library
(@cargonerds/ui-library, the shared component library). The realtime app consumes
ui-library through a workspace symlink and compiles it in place — there is no separate build
step for the library (see The ui-library).
Tech stack¶
Verified against frontend/realtime/package.json.
| Concern | What is used |
|---|---|
| Framework | Next.js ^15.5.9 (App Router, output: 'standalone', Turbopack for dev and build) |
| Runtime | React ^19.1.2 / React-DOM ^19.1.2 |
| Language | TypeScript ^5 |
| Node | 22.x (pinned in engines) |
| Styling | Tailwind CSS v4 (@tailwindcss/postcss, @tailwindcss/typography, container-queries) + tailwind-merge |
| Server state | TanStack Query v5 (@tanstack/react-query ^5.90.2, + devtools), TanStack Table |
| Auth | OIDC via react-oidc-context ^3.3.0 / oidc-client-ts ^3.3.0 (Authorization Code + PKCE) |
| Realtime | @microsoft/signalr ^10.0.0 |
| Maps | MapLibre GL (maplibre-gl ^5.10.0) + MapTiler |
| Forms / validation | react-hook-form, Zod |
| i18n | next-intl ^4.6.1 (messages sourced from ABP, not local JSON) |
| Charts | Recharts ^3.7.0 |
| OData | odata-query ^8.0.5 |
| Shared UI | @cargonerds/ui-library (workspace package) |
| Tests | Vitest |
| Lint/format | ESLint 9, Prettier 3 (Allman braces, printWidth: 120, tabWidth: 4) |
Project layout¶
frontend/realtime/src/
app/ # Next.js App Router
(main)/ # authenticated shell: shipments, orders, bookings,
# quotations, insights, reports, inbox, search,
# profile, settings, ...
globals.css # Tailwind v4 theme + brand tokens
layout.tsx # root layout (server) — builds EnvVars, mounts Providers + ProtectedRoute
providers.tsx # client provider stack (Config → OIDC → Query → AppConfig → Localization)
components/ # shared React components (ProtectedRoute, Header, Sidebar, ...)
context/ # ConfigContext, AppConfigContext, LocalizationProvider,
# GlobalSearchContext, StreamingExportContext
hooks/ # useClient, useServices, useNotificationHub, useFeatures, useLocalization, ...
services/ # ~25 typed API client factories (shipmentService, exportService, ...)
types/ # hand-written DTO mirrors (apiShipment.ts, envVars.ts, ...)
utils/ icons/
Runtime composition (provider stack)¶
The app is assembled in two layers. src/app/layout.tsx is a server component that resolves
runtime configuration per request, and src/app/providers.tsx is the client provider tree.
flowchart TD
L["layout.tsx (server)<br/>dynamic = 'force-dynamic'<br/>reads process.env → EnvVars"] --> P["Providers (client)"]
P --> C["ConfigProvider<br/>(EnvVars carrier)"]
C --> O["OIDCProvider<br/>react-oidc-context AuthProvider"]
O --> Q["QueryClientProvider<br/>(TanStack Query, staleTime 60s)"]
Q --> A["AppConfigProvider<br/>fetches preferences + application-configuration"]
A --> S["CustomScriptInjector"]
A --> Loc["LocalizationProvider<br/>NextIntlClientProvider"]
Loc --> PR["ProtectedRoute → page"]
ProtectedRoute wraps the whole tree (it is mounted by layout.tsx inside <Providers>), so
every page is auth-gated. Authenticated pages additionally render through the (main) route group's
own layout, which mounts the shell (Sidebar/Header/Footer), the SignalR notification hub, and the
global-search / streaming-export providers.
Configuration¶
The app is configured at runtime through a small set of environment variables, typed in
src/types/envVars.ts:
export interface EnvVars
{
apiUrl: string;
authUrl: string;
appUrl: string;
clientId: string;
mapTilerKey: string;
}
| TS field | Env var | Purpose |
|---|---|---|
apiUrl |
API_URL |
Base URL of Cargonerds.HttpApi.Host |
authUrl |
AuthServer__Authority |
OIDC authority (Cargonerds.AuthServer) |
appUrl |
APP_URL |
The app's own public URL (OIDC redirect target) |
clientId |
AUTH_CLIENT_ID |
OIDC client id (defaults to the Aspire resource name) |
mapTilerKey |
MAPTILER_API_KEY |
MapTiler key for MapLibre maps |
Runtime config injection (server → client)¶
Env vars are not baked at build time. layout.tsx is marked export const dynamic =
'force-dynamic', so it runs on the server on every request and reads process.env fresh, with a
per-host fallback table keyed by AZURE_ENVIRONMENT:
const azureUrls: { [id: string]: string } = {
dev: 'dev.spark.cargonerds.dev',
'dev.hub-prod': 'hub-prod-dev.spark.cargonerds.dev',
'dev.hub-test': 'hub-test-dev.spark.cargonerds.dev',
prod: 'rt3.rohlig.com',
'prod.staging': 'staging-prod.spark.cargonerds.dev',
};
const envVars: EnvVars = {
apiUrl: process.env.API_URL || 'https://api.' + azureUrls[process.env.AZURE_ENVIRONMENT || ''],
authUrl: process.env.AuthServer__Authority || 'https://auth.' + azureUrls[process.env.AZURE_ENVIRONMENT || ''],
appUrl: process.env.APP_URL || 'https://' + azureUrls[process.env.AZURE_ENVIRONMENT || ''],
clientId: process.env.AUTH_CLIENT_ID || 'realtime',
mapTilerKey: process.env.MAPTILER_API_KEY || 'jeQ6Cpq9MIEgQuWJOK5W',
};
The resolved EnvVars is passed as a prop into the client ConfigProvider
(src/context/ConfigContext.tsx), so the rest of the app reads it via useConfig() and never
touches process.env on the client.
.NET-style config key
AuthServer__Authority uses the ABP/.NET double-underscore configuration convention rather than
a typical NEXT_PUBLIC_* name. This is intentional so the same key flows from Aspire/host
configuration straight into the Next.js process.
How Aspire wires the env vars¶
In the AppHost, WithNextAuth(...) in
src/Cargonerds.AppHost/Extensions/NodeAppExtensions.cs wires the live sibling endpoints and the
MapTiler key onto the realtime resource:
return builder
.WithSelfEnvironmentHttpsEndpoint("APP_URL")
.WithEnvironmentHttpsEndpoint("API_URL", api)
.WithEnvironmentHttpsEndpoint("AuthServer__Authority", authServer)
.WithEnvironment("AUTH_CLIENT_ID", builder.Resource.Name)
.WithEnvironment("MAPTILER_API_KEY", mapTilerApiKey);
mapTilerApiKey comes from WhiteLabelingOptions.ExternalApis.MapTiler.ApiKey, and AUTH_CLIENT_ID
is simply the Aspire resource name (realtime). The full discovery/port/health-check wiring lives in
DistributedAppBuilderExtensions.cs — see Running and
Aspire integration.
Authentication (OIDC against the ABP AuthServer)¶
OIDCProvider (in providers.tsx) configures react-oidc-context against the ABP
OpenIddict auth server using Authorization Code +
PKCE (the oidc-client-ts default for response_type: 'code'):
const oidcConfig: AuthProviderProps = {
authority: authUrl,
client_id: clientId, // 'realtime'
redirect_uri: appUrl,
post_logout_redirect_uri: appUrl,
scope: 'openid profile email Cargonerds offline_access roles',
response_type: 'code',
response_mode: 'query',
automaticSilentRenew: true,
monitorSession: true,
onSigninCallback: async () => { /* restore oidc_return_path, strip OIDC params */ },
};
- The
Cargonerdsscope is the ABP API resource scope;offline_accessenables refresh tokens, andautomaticSilentRenewkeeps the access token fresh in the background. - Login redirect:
ProtectedRoute(src/components/ProtectedRoute.tsx) callsauth.signinRedirect()whenever auth has finished loading and the user is not authenticated. Before redirecting it stashes the current path insessionStorage['oidc_return_path']. - Return-path sanitization:
onSigninCallbackreads that path back and strips a fixed allow-list of transient OIDC params before replacing the URL, so the user never lands on a callback URL with stale auth parameters:
const OIDC_RESPONSE_PARAMS = [
'code', 'state', 'session_state', 'iss',
'error', 'error_description', 'culture', 'ui-culture',
];
Param allow-list is manual
If the backend ever adds a new transient callback parameter, it must be added to
OIDC_RESPONSE_PARAMS or it will linger in the restored URL.
See Authentication for the server-side OpenIddict configuration.
Token injection — useClient¶
src/hooks/useClient.ts is the single HTTP client. useClient() returns { fetch, rawFetch } and:
- prefixes relative URLs with
apiUrl(absolutehttp(s)://…URLs pass through unchanged); - attaches
Authorization: Bearer <access_token>fromuseAuth()and a JSONContent-Type(unless the body isFormData); fetchparses JSON and returns{ data, error, response };rawFetchreturns the rawResponse(used for SSE/streaming).
headers.set('Authorization', `Bearer ${auth.user?.access_token}`);
headers.set('Accept-Language', 'en-US');
Accept-Language is hard-coded to en-US
Normal API calls through useClient always send Accept-Language: en-US. Only the
application-configuration request (see below) sends the user's chosen language. So most API
payloads come back in en-US regardless of UI language; only localized config/labels follow the
user's culture.
Dynamic claims refresh¶
On mount, ProtectedRoute fires a React Query that calls
dynamicClaimsService.refreshDynamicClaims() →
POST /api/account/dynamic-claims/refresh (src/services/dynamicClaimsService.ts). This warms ABP's
dynamic claims cache, which can be cold after a
server cold-start.
Route-level authorization¶
ProtectedRoute is reusable per route, not just at the root. Beyond the global auth gate it supports:
| Prop | Effect |
|---|---|
requiresGrant |
Require an ABP permission (checked against grantedPolicies) |
requiresAdminUser |
Require an admin user (permission or role claim, via isAdminUser) |
requiredOrganizationUnitId |
Require a specific org unit to be the active organization |
requiredOrganizationUnitName |
Require access to a named org unit |
requiredOrganizationNameIncludes |
Require the active org name to contain a substring |
redirectOnFailureTo / fallback |
Redirect, or render a fallback "Access restricted" panel |
For example, the Rockline insights sidebar link in (main)/layout.tsx is shown only when
isGranted('Hub.Insights.MyInsights.View') and the selected organization name contains
rockline.
App configuration & permissions (ABP application-configuration)¶
AppConfigProvider (src/context/AppConfigContext.tsx) is the client-side mirror of ABP's
configuration. After authentication it fetches, in order:
GET /api/app/profile/preferences→ProfilePreferences(language, timezone, measurementSystem, dateFormat, currency).GET /api/abp/application-configuration— sendingAccept-Language= the user's preferred language — →ApplicationConfiguration, which carriessetting.values,features.values,auth.grantedPolicies,localization(current culture + theRealtime/CodeEntitieslocalization resources) andextraProperties(AppVersion,Environment,BuildDate,WhiteLabeling.footerLinks).
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: async ({ signal }) =>
{
const { data } = await client.fetch<ApplicationConfiguration>('/api/abp/application-configuration', {
headers: { 'Accept-Language': settings?.language ?? '' },
signal,
});
return data;
},
enabled: auth.isAuthenticated && !!settings,
});
Permissions, features and settings¶
- Permissions:
isGranted(permission)returnsconfig.auth.grantedPolicies[permission] === true. TheRealtimePermissionNamesunion inAppConfigContext.tsxkeeps permission strings type-safe (e.g.Hub.Shipment.View,Pricing.Quotation.Add,Hub.RealtimeAdmin,Hub.Insights.MyInsights.View). These mirror backendHubPermissions/PricingPermissions— see Permissions and ABP's authorization & permission management. - Features & settings:
useFeatures()(src/hooks/useFeatures.ts) readsfeatures.valuesandsetting.values, comparing against the string'true'. AHubSettingsconstant block mirrorsmodules/hub/src/Hub.Domain/Settings/HubSettings.cs(e.g.Hub.CustomScript.Enabled,Hub.Commodity.FilteredView). See ABP features and settings.
Keep mirrors in sync
RealtimePermissionNames and HubSettings are hand-maintained mirrors of C# identifiers. There
is no codegen — they must be updated when the backend permission/setting names change.
Localization (next-intl driven by ABP)¶
There is no local messages JSON and no i18n routing/middleware. LocalizationProvider
(src/context/LocalizationProvider.tsx) feeds NextIntlClientProvider with locale =
config.localization.currentCulture.culture and messages built from the ABP localization.values
maps (Realtime + CodeEntities). Because next-intl rejects dots in message keys, keys containing
. are filtered out:
const messages = {
...Object.fromEntries(Object.entries(values?.Realtime ?? {}).filter(([k]) => !k.includes('.'))),
CodeEntities: Object.fromEntries(Object.entries(values?.CodeEntities ?? {}).filter(([k]) => !k.includes('.'))),
};
Dotted keys are silently dropped
Any ABP localization key that
contains a . is unavailable to t() on the client (except the CodeEntities namespace, which
is nested deliberately). Date/time formatting is handled separately by useLocalization()
(src/hooks/useLocalization.ts) using date-fns + @date-fns/tz. See
Localization for details.
Data access (REST + OData)¶
All requests go through useClient().fetch. Components never call services directly; instead
src/hooks/useServices.ts instantiates ~25 service factories with the shared client and exposes them
through a single useServices() hook:
export const useServices = () =>
{
const client = useClient();
const { mapTilerKey, authUrl } = useConfig();
return {
shipmentService: shipmentService(client),
orderService: orderService(client),
exportService: exportService(client),
// ... ~25 services
};
};
Each service is a plain function service(client) => ({ ...methods }) under src/services/.
No generated API client
There is no @abp/ng.* proxy and no NSwag/OpenAPI codegen. Every request/response type
under src/types/api*.ts is a hand-written mirror of a backend DTO and can drift from the
backend. Note that ABP serializes enums as integers by default, so the TS types use numeric
enums to match. See API clients.
Two consumption styles¶
The app calls the backend in two parallel styles, both through the same client:
| Style | Shape | Examples |
|---|---|---|
| ABP auto-API REST | /api/app/<service>/<method> (+ ABP framework endpoints) |
/api/app/shipment, /api/app/profile/preferences, /api/abp/application-configuration, /api/account/dynamic-claims/refresh |
| OData | /odata/<EntitySet> with $filter/$expand/$select/$orderby/$top/$skip/$count/$apply |
/odata/Shipment, /odata/CalculatedShipment, /odata/Invoice, /odata/ShipmentBaseDocument |
REST path/query shapes follow ABP's
auto API controller
conventions (e.g. the first Guid argument becomes a path segment).
shipmentService.ts shows both styles side by side — a legacy REST list endpoint kept for facets,
plus OData queries built with odata-query's buildQuery:
const qCore = buildQuery({ filter: filterObject, search, orderBy, select, expand: expandObj, skip, top, count });
let url = `/odata/Shipment${qCore}`;
const options: RequestInit = {
...(opts?.withFacets && { headers: { Prefer: 'odata.include-annotations="*"' } }),
};
const { data } = await client.fetch<{
value: ApiShipment[];
['@odata.count']: number;
['@cn.facets']: DynamicFilter[];
}>(url, options);
OData responses are read as { value, '@odata.count', '@cn.facets' }. @cn.facets is a custom
backend annotation that returns filter facets, and it is only emitted when the request sends the
Prefer: odata.include-annotations="*" header (withFacets: true). Aggregations use raw
$apply=... (groupby/aggregate), e.g. in orderService and rocklineDashboardService. See
OData & filtering and the general REST API page.
Realtime transports¶
SignalR notifications¶
src/hooks/useNotificationHub.ts is mounted once, in (main)/layout.tsx. It connects to ABP's
notification hub and, on every server ReceiveNotification push, invalidates the inbox and
unread-count React Query caches — so the inbox list and the unread badge refresh without polling
(the same invalidation also runs on onreconnected).
// ABP convention: /signalr-hubs/{hub-name}
const hubUrl = `${apiUrl}/signalr-hubs/notification`;
const connection = new HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: () => tokenRef.current,
transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(LogLevel.None)
.build();
The hub URL follows ABP's /signalr-hubs/{hub-name} convention, and the connection authenticates via
accessTokenFactory (reading a ref-held token so a renewed token is picked up).
Streaming Excel export (SSE)¶
exportService.streamExport (src/services/exportService.ts) POSTs to
/api/app/excel-export/stream-export with Accept: text/event-stream (via rawFetch), then
readSseStream (src/services/sseClient.ts) parses newline-delimited JSON events. The event
protocol is a discriminated union:
export type ExportStreamEvent =
| { type: 'progress'; phase: string; percent: number; done?: number; total?: number }
| { type: 'ready'; fileName: string; downloadUrl: string }
| { type: 'error'; message: string };
progressdrives a percent UI;readytriggers a native browser download againstdownloadUrl— the file bytes never pass through JavaScript memory;errorcarries a server-side failure message.
StreamingExportProvider lives at the (main) layout level so an in-flight export survives
client-side navigation. Per the service/provider comments, if the caller aborts (e.g. the tab is
closed) the server falls back to emailing the finished file. The reader drops malformed JSON lines
silently rather than tearing down the stream, and is abort-safe.
The same exportService also exposes REST CRUD for scheduled export jobs
(/api/app/excel-export/queue-export, /scheduled-jobs, /{jobId}/scheduled-job, etc.).
The ui-library¶
frontend/ui-library (@cargonerds/ui-library) is a source-only, un-built shared React
component library. Its package.json points main/types/exports straight at ./index.ts:
{
"name": "@cargonerds/ui-library",
"main": "./index.ts",
"types": "./index.ts",
"exports": { ".": "./index.ts" },
"sideEffects": ["**/*.css"],
"peerDependencies": { "next": "^15.5.9", "react": "^19.1.2", "react-dom": "^19.1.2", "tailwind-merge": "^3.0.0" }
}
index.ts re-exports ~90 components and types — primitives and composite widgets built on
Radix UI + Tailwind: Button, Modal, Tabs, Select/MultiSelect/SearchableSelect,
InfiniteScrollTable/ReactTable, FilterBar/DynamicFilterButton, Calendar, chart tiles,
MapTiles, Editor, and more.
How it is consumed and built:
realtimedepends on it as"@cargonerds/ui-library": "*", resolved to the workspace via thenode_modulessymlink.next.config.tslists it intranspilePackages, so Next.js compiles the library's raw TS/JSX in place:
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@cargonerds/ui-library'],
turbopack: { rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js' } } },
async rewrites() { return [{ source: '/brokerage/:id', destination: '/shipments/:id' }]; },
};
- SVG icons are turned into React components by the
@svgr/webpackTurbopack rule. The icon-name union is generated bynpm run generate:icon-names(in the ui-library workspace), which scansicons/*.svginto a typedIconNamesunion. - Styling uses Tailwind utility classes merged with
tailwind-merge(twMerge); brand tokens such asbg-brand-100/text-grey-125are defined in the app'sglobals.cssTailwind v4 theme — see Theming.
Consumers must transpile the library
Because the library ships raw TypeScript with no build, any consumer must list it in
transpilePackages and satisfy its peerDependencies (React 19, Next 15, tailwind-merge).
Running¶
Via Aspire (recommended)¶
AddAllFrontends() →
AddAllNpmAppsInPath(CargonerdsConsts.Aspire.Directories.Frontends) in
src/Cargonerds.AppHost/Extensions/DistributedAppBuilderExtensions.cs auto-discovers every folder
under frontend/ and regenerates frontend/package.json (CreatePackageJson) so the
workspaces array always reflects the real subfolders (respecting .dockerignore). This is why
frontend/package.json is a minimal generated file. Each app directory that contains a Dockerfile
is registered with AddJavaScriptApp(appName, app, startScript).
string startScript = scriptNames?.FirstOrDefault(n => n.Contains("aspire") || n.Contains("start")) ?? "start";
var nodeApp = builder
.AddJavaScriptApp(appName, app, startScript)
.WithEnvironment("BROWSER", "none")
.WithHttpEndpoint(port: fixedPort, env: "PORT", name: "http")
.WithExternalHttpEndpoints()
.WithHttpHealthCheck("/", 200)
.WithOtlpExporter()
.PublishAsDockerFile(/* ... */)
.IfRunMode(b => b.WithEnvironment("NODE_TLS_REJECT_UNAUTHORIZED", "0"));
Concretely for realtime:
- the start script is chosen as the first script whose name contains
aspireorstart, so it runsaspire-dev(npm i --force && next dev --turbopack --hostname 0.0.0.0); - in run mode it gets a fixed port from
RunModeServicePorts.RealtimeHttpPort = 4200, plusBROWSER=none, an OTLP exporter, a/health check (200), external HTTP endpoints, andNODE_TLS_REJECT_UNAUTHORIZED=0(matching.env.example);
- in publish mode, because
realtime'sdependenciesinclude the ui-library package name, it is published via itsDockerfilewith the frontend workspace root as the Docker build context — so the whole monorepo (including the un-built ui-library) is available during the image build.
See Aspire integration for the full picture and ABP's .NET Aspire integration.
aspire-dev runs npm i --force on every start
Aspire reinstalls dependencies on each launch, so the first run can be slow and can mask lockfile issues.
Standalone¶
Other scripts (package.json):
npm run build # next build --turbopack
npm run start # next start
npm run test # vitest run
npm run lint # eslint .
npm run type:check # tsc --pretty --noEmit --project tsconfig.json
npm run prettier:fix # prettier --write .
Deployment¶
The app ships a multi-stage frontend/realtime/Dockerfile (deps → builder → runner, Node 22
Alpine). It installs the whole workspace with npm ci, builds only the realtime workspace, and
runs the Next.js standalone server:
ARG NODE_VERSION=22
# ... deps: npm ci over the whole frontend workspace ...
RUN npm run build --workspace=realtime # builder stage
# ... runner stage ...
ENV PORT=8080
EXPOSE 8080
CMD ["node", "realtime/server.js"]
It is deployed as a container alongside the backend services — see Deployment overview, Azure Container Apps and the env-var reference in Deployment configuration.
Theming¶
Brand colors and the Tailwind v4 theme are defined in src/app/globals.css, and the default branding
is Röhlig (page title, RohligLogo.svg, Usercentrics consent ID zO90Ac4bE). White-labeling is
data-driven via extraProperties.WhiteLabeling from the application-configuration response. See
Theming.
Gotchas¶
Things to watch out for
- No generated API client. Types under
src/types/api*.tsare hand-written and can drift; ABP serializes enums as integers, so the TS types must use numeric enums. Accept-Languageis hard-coded toen-USinuseClientfor normal API calls, while the application-configuration request uses the user's actual language.tsconfig.jsonlists a non-existentsrc/middleware.tsininclude. There is no Next middleware and no localized routing; i18n is entirely client-side via next-intl fed from ABP config.- Two ESLint configs coexist — the flat
eslint.config.mjs(minimal) and a legacy.eslintrc.json(the richer rule set, incl.react-compiler/react-compiler: error,simple-import-sort,no-console). - next-intl drops keys containing
.(see Localization). @cn.facets+Prefer: odata.include-annotations="*"are custom backend conventions; OData list endpoints return facets only when that header is sent (withFacets: true).- OIDC return-path sanitization is an explicit param allow-list (
OIDC_RESPONSE_PARAMS); new transient params must be added there. mapTilerKeyhas a committed default inlayout.tsx('jeQ6Cpq9MIEgQuWJOK5W') used whenMAPTILER_API_KEYis unset.- The ui-library ships raw TypeScript — consumers must
transpilePackagesit and match its peer deps.