@mpfrontend/nx-plugin 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +89 -2
  3. package/dist/core.d.ts +22 -0
  4. package/dist/core.js +46 -0
  5. package/dist/core.js.map +1 -0
  6. package/dist/feature-generator.d.ts +7 -0
  7. package/dist/feature-generator.js +9 -0
  8. package/dist/feature-generator.js.map +1 -0
  9. package/dist/generator.d.ts +6 -0
  10. package/dist/generator.js +14 -0
  11. package/dist/generator.js.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +2 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/internal.d.ts +1 -0
  16. package/dist/internal.js +3 -0
  17. package/dist/internal.js.map +1 -0
  18. package/dist/nx-tree.d.ts +5 -0
  19. package/dist/nx-tree.js +12 -0
  20. package/dist/nx-tree.js.map +1 -0
  21. package/dist/package-generator.d.ts +8 -0
  22. package/dist/package-generator.js +9 -0
  23. package/dist/package-generator.js.map +1 -0
  24. package/dist/route-generator.d.ts +8 -0
  25. package/dist/route-generator.js +9 -0
  26. package/dist/route-generator.js.map +1 -0
  27. package/dist/scaffold.d.ts +96 -0
  28. package/dist/scaffold.js +227 -0
  29. package/dist/scaffold.js.map +1 -0
  30. package/dist/templates.d.ts +21 -0
  31. package/dist/templates.js +83 -0
  32. package/dist/templates.js.map +1 -0
  33. package/dist/workspace.d.ts +41 -0
  34. package/dist/workspace.js +213 -0
  35. package/dist/workspace.js.map +1 -0
  36. package/generators.json +24 -0
  37. package/package.json +41 -3
  38. package/schema.json +18 -0
  39. package/schemas/feature-schema.json +25 -0
  40. package/schemas/package-schema.json +28 -0
  41. package/schemas/route-schema.json +31 -0
  42. package/templates/feature/list/api/server/__resource__.ts.template +24 -0
  43. package/templates/feature/list/app/api/__resource__/route.ts.template +24 -0
  44. package/templates/feature/list/entities/__resource__/index.ts.template +12 -0
  45. package/templates/feature/list/entities/__resource__/model/__resource__-list.test.ts.template +23 -0
  46. package/templates/feature/list/entities/__resource__/model/__resource__-list.ts.template +24 -0
  47. package/templates/feature/list/entities/__resource__/model/__resource__.test.ts.template +17 -0
  48. package/templates/feature/list/entities/__resource__/model/__resource__.ts.template +19 -0
  49. package/templates/feature/list/features/__feature__/api/__feature__-read.ts.template +12 -0
  50. package/templates/feature/list/features/__feature__/api/__feature__.ts.template +28 -0
  51. package/templates/feature/list/features/__feature__/hooks/use-__feature__-list.ts.template +22 -0
  52. package/templates/feature/list/features/__feature__/index.ts.template +3 -0
  53. package/templates/feature/list/features/__feature__/model/__feature__-fields.ts.template +4 -0
  54. package/templates/feature/list/features/__feature__/model/overrides/index.ts.template +3 -0
  55. package/templates/feature/list/features/__feature__/ui/__feature__-detail-screen.tsx.template +16 -0
  56. package/templates/feature/list/features/__feature__/ui/__feature__-detail.test.tsx.template +28 -0
  57. package/templates/feature/list/features/__feature__/ui/__feature__-detail.tsx.template +44 -0
  58. package/templates/feature/list/features/__feature__/ui/__feature__-list.tsx.template +75 -0
  59. package/templates/feature/list/features/__feature__/ui/__feature__-screen.tsx.template +31 -0
  60. package/templates/feature/list/features/__feature__/utils/__feature__-href.test.ts.template +7 -0
  61. package/templates/feature/list/features/__feature__/utils/__feature__-href.ts.template +4 -0
  62. package/templates/feature/screen/features/__feature__/api/__feature__.ts.template +13 -0
  63. package/templates/feature/screen/features/__feature__/hooks/use-__feature__.ts.template +27 -0
  64. package/templates/feature/screen/features/__feature__/index.ts.template +3 -0
  65. package/templates/feature/screen/features/__feature__/model/__feature__.test.ts.template +12 -0
  66. package/templates/feature/screen/features/__feature__/model/__feature__.ts.template +12 -0
  67. package/templates/feature/screen/features/__feature__/ui/__feature__-panel.tsx.template +22 -0
  68. package/templates/feature/screen/features/__feature__/ui/__feature__-screen.tsx.template +15 -0
  69. package/templates/feature/screen/features/__feature__/ui/__feature__-view.test.tsx.template +25 -0
  70. package/templates/feature/screen/features/__feature__/ui/__feature__-view.tsx.template +38 -0
  71. package/templates/feature/screen/features/__feature__/utils/__feature__.test.ts.template +9 -0
  72. package/templates/feature/screen/features/__feature__/utils/__feature__.ts.template +6 -0
  73. package/templates/next/README.md.template +10 -0
  74. package/templates/next/docs/api-contracts.md.template +22 -0
  75. package/templates/next/docs/overview.md.template +67 -0
  76. package/templates/next/eslint.config.mjs.template +4 -0
  77. package/templates/next/ftg.config.json.template +11 -0
  78. package/templates/next/next-env.d.ts.template +2 -0
  79. package/templates/next/next.config.mjs.template +9 -0
  80. package/templates/next/package.json.template +27 -0
  81. package/templates/next/postcss.config.mjs.template +2 -0
  82. package/templates/next/prepare-runtime.mjs.template +11 -0
  83. package/templates/next/project.json.template +113 -0
  84. package/templates/next/src/api/server/transfer.test.ts.template +153 -0
  85. package/templates/next/src/api/server/transfer.ts.template +25 -0
  86. package/templates/next/src/app/api/access/route.ts.template +6 -0
  87. package/templates/next/src/app/api/docs/assets/[file]/route.ts.template +23 -0
  88. package/templates/next/src/app/api/docs/route.ts.template +8 -0
  89. package/templates/next/src/app/api/openapi/route.ts.template +12 -0
  90. package/templates/next/src/app/api/transfer/[...path]/route.ts.template +22 -0
  91. package/templates/next/src/app/components/page.tsx.template +32 -0
  92. package/templates/next/src/app/globals.css.template +32 -0
  93. package/templates/next/src/app/layout.tsx.template +53 -0
  94. package/templates/next/src/app/page.tsx.template +6 -0
  95. package/templates/next/src/app/providers.tsx.template +31 -0
  96. package/templates/next/src/config/access.ts.template +21 -0
  97. package/templates/next/src/config/app.ts.template +33 -0
  98. package/templates/next/src/config/server.ts.template +13 -0
  99. package/templates/next/src/features/preferences/api/preference-cookie.ts.template +14 -0
  100. package/templates/next/src/features/preferences/hooks/use-preferences.ts.template +30 -0
  101. package/templates/next/src/features/preferences/index.ts.template +3 -0
  102. package/templates/next/src/features/preferences/model/messages/en.ts.template +10 -0
  103. package/templates/next/src/features/preferences/model/messages.ts.template +13 -0
  104. package/templates/next/src/features/preferences/model/preferences.test.ts.template +32 -0
  105. package/templates/next/src/features/preferences/model/preferences.ts.template +22 -0
  106. package/templates/next/src/features/preferences/ui/preference-controls.tsx.template +50 -0
  107. package/templates/next/src/i18n/index.ts.template +13 -0
  108. package/templates/next/src/i18n/messages/en.ts.template +18 -0
  109. package/templates/next/src/i18n/server.ts.template +8 -0
  110. package/templates/next/src/theme/config.test.ts.template +9 -0
  111. package/templates/next/src/theme/config.ts.template +7 -0
  112. package/templates/next/src/theme/theme.css.template +8 -0
  113. package/templates/next/tsconfig.json.template +11 -0
  114. package/templates/package/README.md.template +10 -0
  115. package/templates/package/eslint.config.mjs.template +4 -0
  116. package/templates/package/package.json.template +15 -0
  117. package/templates/package/project.json.template +51 -0
  118. package/templates/package/src/index.ts.template +2 -0
  119. package/templates/package/src/package-name.test.ts.template +7 -0
  120. package/templates/package/src/package-name.ts.template +2 -0
  121. package/templates/package/tsconfig.json.template +7 -0
  122. package/templates/package/tsconfig.lib.json.template +9 -0
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The request boundary of the feature. Bind a request to a generated contract adapter of the app; every
3
+ * call is cancellable and bounded in time, and nothing here decides authorization.
4
+ */
5
+ export type __Feature__Request = (signal: AbortSignal) => Promise<void>;
6
+ export const REQUEST_TIMEOUT_MS = 10000;
7
+
8
+ export async function run__Feature__(
9
+ request: __Feature__Request,
10
+ signal: AbortSignal,
11
+ ): Promise<void> {
12
+ await request(AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]));
13
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { run__Feature__, type __Feature__Request } from '../api/__feature__';
4
+ import { next__Feature__Status, type __Feature__Status } from '../model/__feature__';
5
+ import { is__Feature__Busy } from '../utils/__feature__';
6
+
7
+ /** The feature state and its orchestration: start, cancel on unmount, and map the outcome. */
8
+ export function use__Feature__(request: __Feature__Request | undefined) {
9
+ const [status, setStatus] = useState<__Feature__Status>('idle');
10
+ const active = useRef<AbortController | null>(null);
11
+ useEffect(() => () => active.current?.abort(), []);
12
+ async function start() {
13
+ if (!request || is__Feature__Busy(status)) return;
14
+ const controller = new AbortController();
15
+ active.current = controller;
16
+ setStatus((current) => next__Feature__Status(current, 'start'));
17
+ try {
18
+ await run__Feature__(request, controller.signal);
19
+ if (!controller.signal.aborted)
20
+ setStatus((current) => next__Feature__Status(current, 'succeed'));
21
+ } catch {
22
+ if (!controller.signal.aborted)
23
+ setStatus((current) => next__Feature__Status(current, 'fail'));
24
+ }
25
+ }
26
+ return { status, start, available: request !== undefined };
27
+ }
@@ -0,0 +1,3 @@
1
+ // The public entry of the feature. Code outside the feature imports only from here.
2
+ export { __Feature__Screen, type __Feature__ScreenProps } from './ui/__feature__-screen';
3
+ export type { __Feature__Request } from './api/__feature__';
@@ -0,0 +1,12 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { next__Feature__Status } from './__feature__';
4
+
5
+ test('a result is accepted only while the workflow is running', () => {
6
+ assert.equal(next__Feature__Status('idle', 'start'), 'running');
7
+ assert.equal(next__Feature__Status('running', 'succeed'), 'done');
8
+ assert.equal(next__Feature__Status('running', 'fail'), 'failed');
9
+ assert.equal(next__Feature__Status('failed', 'start'), 'running');
10
+ assert.equal(next__Feature__Status('idle', 'succeed'), 'idle');
11
+ assert.equal(next__Feature__Status('done', 'fail'), 'done');
12
+ });
@@ -0,0 +1,12 @@
1
+ /** The workflow states of the feature and the only transitions between them. */
2
+ export type __Feature__Status = 'idle' | 'running' | 'done' | 'failed';
3
+ export type __Feature__Event = 'start' | 'succeed' | 'fail';
4
+
5
+ export function next__Feature__Status(
6
+ status: __Feature__Status,
7
+ event: __Feature__Event,
8
+ ): __Feature__Status {
9
+ if (event === 'start') return status === 'running' ? status : 'running';
10
+ if (status !== 'running') return status;
11
+ return event === 'succeed' ? 'done' : 'failed';
12
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import type { __Feature__Request } from '../api/__feature__';
3
+ import { use__Feature__ } from '../hooks/use-__feature__';
4
+ import { __Feature__View } from './__feature__-view';
5
+
6
+ /**
7
+ * The panel binds the feature to its request. Until a request is bound to a generated contract adapter,
8
+ * the feature renders as not available rather than inventing an endpoint.
9
+ */
10
+ const request: __Feature__Request | undefined = undefined;
11
+
12
+ export function __Feature__Panel({ language }: { language: string }) {
13
+ const state = use__Feature__(request);
14
+ return (
15
+ <__Feature__View
16
+ status={state.status}
17
+ available={state.available}
18
+ language={language}
19
+ onStart={state.start}
20
+ />
21
+ );
22
+ }
@@ -0,0 +1,15 @@
1
+ import { cookies } from 'next/headers';
2
+ import { preferenceCookie, visitorLocale } from '../../../config/app';
3
+ import { __Feature__Panel } from './__feature__-panel';
4
+
5
+ export type __Feature__ScreenProps = Readonly<{
6
+ params?: Promise<Readonly<Record<string, string>>>;
7
+ searchParams?: Promise<Readonly<Record<string, string | string[] | undefined>>>;
8
+ basePath: string;
9
+ }>;
10
+
11
+ /** The screen of the feature: it resolves the visitor's locale on the server and renders the panel. */
12
+ export async function __Feature__Screen(_props: __Feature__ScreenProps) {
13
+ const language = visitorLocale((await cookies()).get(preferenceCookie.name)?.value);
14
+ return <__Feature__Panel language={language} />;
15
+ }
@@ -0,0 +1,25 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createElement } from 'react';
4
+ import { renderToStaticMarkup } from 'react-dom/server';
5
+ import { __Feature__View } from './__feature__-view';
6
+
7
+ const render = (props: Parameters<typeof __Feature__View>[0]) =>
8
+ renderToStaticMarkup(createElement(__Feature__View, props));
9
+
10
+ test('an unbound feature is announced and cannot start', () => {
11
+ const html = render({ status: 'idle', available: false, language: 'en', onStart: () => {} });
12
+ assert.match(html, /aria-disabled="true"/);
13
+ assert.match(html, /role="status"/);
14
+ });
15
+
16
+ test('a running feature shows its busy state and a failure is an alert', () => {
17
+ assert.match(
18
+ render({ status: 'running', available: true, language: 'en', onStart: () => {} }),
19
+ /aria-busy="true"/,
20
+ );
21
+ assert.match(
22
+ render({ status: 'failed', available: true, language: 'en', onStart: () => {} }),
23
+ /role="alert"/,
24
+ );
25
+ });
@@ -0,0 +1,38 @@
1
+ import { PageFrame } from '@mpfrontend/app-layout';
2
+ import { Button, Card } from '@mpfrontend/ui';
3
+ import type { __Feature__Status } from '../model/__feature__';
4
+ import { messages } from '../model/messages';
5
+
6
+ type Props = Readonly<{
7
+ status: __Feature__Status;
8
+ available: boolean;
9
+ language: string;
10
+ onStart: () => void;
11
+ }>;
12
+
13
+ /** The view renders the state it is given and emits the start intent; it holds no workflow state. */
14
+ export function __Feature__View({ status, available, language, onStart }: Props) {
15
+ const t = messages.translator(language);
16
+ const message = !available
17
+ ? t('unbound')
18
+ : status === 'done'
19
+ ? t('done')
20
+ : status === 'failed'
21
+ ? t('failed')
22
+ : '';
23
+ return (
24
+ <PageFrame title={t('title')}>
25
+ <Card>
26
+ <Button
27
+ onClick={onStart}
28
+ restricted={!available}
29
+ loading={status === 'running'}
30
+ loadingLabel={t('running')}
31
+ >
32
+ {t('run')}
33
+ </Button>
34
+ <p role={status === 'failed' ? 'alert' : 'status'}>{message}</p>
35
+ </Card>
36
+ </PageFrame>
37
+ );
38
+ }
@@ -0,0 +1,9 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { is__Feature__Busy } from './__feature__';
4
+
5
+ test('only a running workflow is busy', () => {
6
+ assert.equal(is__Feature__Busy('running'), true);
7
+ for (const status of ['idle', 'done', 'failed'] as const)
8
+ assert.equal(is__Feature__Busy(status), false);
9
+ });
@@ -0,0 +1,6 @@
1
+ import type { __Feature__Status } from '../model/__feature__';
2
+
3
+ /** Whether the view should block a second start. */
4
+ export function is__Feature__Busy(status: __Feature__Status): boolean {
5
+ return status === 'running';
6
+ }
@@ -0,0 +1,10 @@
1
+ # __APP_NAME__
2
+
3
+ Generated by the MP Frontend CLI. This app consumes packed platform packages and has no source alias into
4
+ another repository. Product labels and behavior belong to authored overrides. The first slice is the
5
+ public catalog: a list route and a detail route over one generated read. Protected writes, identity and
6
+ retained realtime require their separate gates.
7
+
8
+ See [docs/overview.md](docs/overview.md) for the routes and the layout, and
9
+ [docs/api-contracts.md](docs/api-contracts.md) for the contracts. `.env.example` names the environment
10
+ variables the app reads, without values.
@@ -0,0 +1,22 @@
1
+ # API contracts
2
+
3
+ - Captured contract: `contracts/openapi/presentation/__APP_NAME__/openapi.json`, an approved and
4
+ immutable OpenAPI input of this workspace.
5
+ - Selection: `ftg.config.json` selects the operations this app uses. The `ftg-generate` target writes
6
+ `src/api/generated/catalog/`, and the `generated-check` target refuses drift.
7
+ - Server boundary: `src/api/server/catalog.ts` sends the BFF only the parameters of the list protocol in
8
+ `src/entities/catalog/model/catalog-list.ts` and validates the response with the generated reader before a
9
+ feature sees it. The test beside the protocol checks it against the captured contract: a parameter that
10
+ `listCatalog` does not declare fails it.
11
+ - Transfers: `src/app/api/transfer/[...path]/route.ts` relays the BFF's streamed routes under
12
+ `/__APP_NAME__/transfer/` (uploads and downloads, as streams) through `src/api/server/transfer.ts`. It
13
+ forwards only the session cookie named by `BFF_SESSION_COOKIE`, Origin, the CSRF token, the
14
+ idempotency key, Accept-Language and the content headers; the BFF's routes decide which paths, media
15
+ types and sizes exist.
16
+ - Example read: the template reads `listCatalog`, which declares the query parameters `page`, `size` and
17
+ `search` and answers a paged response with `items`, `number`, `total`, `pageCount` and `hasMore`. Replace it
18
+ with an approved contract of this product, and its list protocol with the way that contract pages, sorts and
19
+ filters.
20
+
21
+ Record each further operation here when `ftg.config.json` selects it: its operation id, the screens that
22
+ use it and what the app assumes about it.
@@ -0,0 +1,67 @@
1
+ # __APP_NAME__
2
+
3
+ The purpose of this app, its users and its terms belong at the top of this document. The sections below
4
+ describe the structure that `mpfrontend create app` wrote.
5
+
6
+ ## Routes
7
+
8
+ - `/` opens the catalog list (`src/app/page.tsx`).
9
+ - `/catalog` is the catalog list screen; its page, size and search are URL state, read the same way on the
10
+ server for the first render and in the browser, so the screen can be linked and refreshed and back and
11
+ forward move through it (`src/app/catalog/page.tsx`). The browser reads further pages through the app's
12
+ own route `/api/catalog` (`src/app/api/catalog/route.ts`), under the access scope.
13
+ - `/catalog/[position]` is the detail screen of one item, a child route of the list
14
+ (`src/app/catalog/[position]/page.tsx`).
15
+ - `/components` shows the framework components (`src/app/components/page.tsx`).
16
+ - `/api/access` answers the access snapshot of the visitor again when the window regains focus
17
+ (`src/app/api/access/route.ts`); `src/config/access.ts` defines it, and `src/app/providers.tsx` holds the app's
18
+ query client, its URL state adapter and its access provider.
19
+ - `/api/transfer/...` relays the BFF's streamed routes for uploads and downloads
20
+ (`src/app/api/transfer/[...path]/route.ts`); see [api-contracts.md](api-contracts.md).
21
+
22
+ ## Layout
23
+
24
+ - `src/app/` holds thin routes, the root layout and route handlers only.
25
+ - `src/features/<feature>/` holds one user workflow: `ui/`, `model/`, `hooks/`, `api/`, `utils/` and its
26
+ `index.ts` public entry. Code outside a feature imports only that `index.ts`; lint refuses a deeper
27
+ import.
28
+ - `src/entities/<entity>/` holds the types and rules that the features of one entity share.
29
+ - `src/api/generated/` holds generated contracts; `src/api/server/` holds the hand-written server
30
+ boundary beside them.
31
+ - `src/config/` holds validated configuration; `src/theme/` holds the theme registry.
32
+
33
+ ## Languages
34
+
35
+ The app starts with English only (`en`, left to right, the default), and its `lang` and `dir` follow the
36
+ locale registry in `src/config/app.ts`. To add a locale in this repository:
37
+
38
+ 1. Register it with its direction, `'<code>': { direction: 'ltr' }` or `'<code>': { direction: 'rtl' }`, and
39
+ a `numberingSystem` when its digits differ from the default.
40
+ 2. Add `messages/<code>.ts` next to `messages/en.ts` in every catalog set, typed
41
+ `Translation<typeof base>`, and list it in the `translations` of that set.
42
+ 3. Run the `catalog-check` target: it names every set that lacks the catalog or a key.
43
+
44
+ To use another locale instead of English, register only that locale and make it `defaultLocale`, rename
45
+ every `messages/en.ts` to `messages/<code>.ts` with the text of that locale, and set the base import and
46
+ `defaultLocale` of every set to `<code>`. New features then start from that locale's catalog.
47
+
48
+ Add a feature with `mpfrontend create feature`, a screen with `mpfrontend create route` and a package
49
+ that two apps need with `mpfrontend create package`; see the CLI guide of MP Frontend.
50
+
51
+ ## Folders created when needed
52
+
53
+ The generator does not create these empty; create each one with its first file, under this rule.
54
+
55
+ | Location | Holds | Create it when |
56
+ | --- | --- | --- |
57
+ | `src/ui/` | Components shared by features of this app | A second feature needs a component that one feature holds; a component of one feature stays in its `ui/` |
58
+ | `src/hooks/` | Domain-neutral React hooks reused by features of this app | A second feature needs a hook that carries no domain rule; a hook of one feature stays in its `hooks/` |
59
+ | `src/utils/` | Domain-neutral pure helpers reused within this app | A second feature needs a helper without React, network or domain rule; a helper of one feature stays in its `utils/` |
60
+ | `src/tests/` | Integration, contract, end-to-end and smoke tests, and their mock fixtures | The first test covers more than one feature or the app as a whole; a unit test stays beside the file it tests |
61
+ | `public/` | Static assets this app owns | The app has its first static asset |
62
+ | `docs/business-rules.md` | The rules and constraints of this app | The first business rule or constraint is agreed |
63
+ | `docs/adr/` | Architecture decisions of this app, one file each | The first decision is taken that concerns this app only; a decision for every app goes in the workspace's `docs/` |
64
+ | `infra/` (workspace root) | A local proxy example and the deployment templates of each app, without secrets | The workspace first needs a local proxy or a deployment template |
65
+
66
+ Code that two apps need goes in a package (`mpfrontend create package`), never in one app's `src/`.
67
+ The contracts this app reads are listed in [api-contracts.md](api-contracts.md).
@@ -0,0 +1,4 @@
1
+ import workspace from '../../eslint.config.mjs';
2
+
3
+ // The workspace profile applies unchanged; app-specific additions go after it.
4
+ export default [...workspace];
@@ -0,0 +1,11 @@
1
+ {
2
+ "openapi": "../../contracts/openapi/presentation/__APP_NAME__/openapi.json",
3
+ "output": "src/api/generated/catalog",
4
+ "resources": [
5
+ {
6
+ "name": "catalog",
7
+ "operationId": "listCatalog",
8
+ "responsePath": ["items"]
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,2 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
@@ -0,0 +1,9 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /** @type {import('next').NextConfig} */
3
+ const config = {
4
+ poweredByHeader: false,
5
+ reactStrictMode: true,
6
+ outputFileTracingRoot: fileURLToPath(new URL('../..', import.meta.url)),
7
+ serverExternalPackages: ['swagger-ui-dist'],
8
+ };
9
+ export default config;
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@mpfrontend-reference/__APP_NAME__",
3
+ "version": "0.1.0-dev.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@mpfrontend/access-core": "1.0.0",
8
+ "@mpfrontend/access-react": "1.0.0",
9
+ "@mpfrontend/app-layout": "1.0.0",
10
+ "@mpfrontend/data-react": "1.0.0",
11
+ "@mpfrontend/ftg-core": "1.0.0",
12
+ "@mpfrontend/i18n": "1.0.0",
13
+ "@mpfrontend/presentation-server": "1.0.0",
14
+ "@mpfrontend/tokens": "1.0.0",
15
+ "@mpfrontend/ui": "1.0.0",
16
+ "@tailwindcss/postcss": "4.3.3",
17
+ "@tanstack/react-query": "5.104.0",
18
+ "ajv": "8.20.0",
19
+ "ajv-formats": "3.0.1",
20
+ "next": "16.3.8",
21
+ "nuqs": "2.10.1",
22
+ "react": "19.3.0",
23
+ "react-dom": "19.3.0",
24
+ "swagger-ui-dist": "5.29.5",
25
+ "tailwindcss": "4.3.3"
26
+ }
27
+ }
@@ -0,0 +1,2 @@
1
+ // Tailwind CSS v4 compiles the utility classes of the app and of the MP Frontend UI components.
2
+ export default { plugins: { '@tailwindcss/postcss': {} } };
@@ -0,0 +1,11 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ const root = fileURLToPath(new URL('./', import.meta.url)),
6
+ require = createRequire(resolve(root, 'package.json'));
7
+ const directory = resolve(root, 'runtime-assets/swagger');
8
+ await mkdir(directory, { recursive: true });
9
+ for (const file of ['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js'])
10
+ await copyFile(require.resolve('swagger-ui-dist/' + file), resolve(directory, file));
11
+ console.log('Prepared pinned Swagger runtime assets');
@@ -0,0 +1,113 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "projectType": "application",
4
+ "tags": ["scope:__APP_NAME__", "runtime:mixed", "type:app"],
5
+ "targets": {
6
+ "ftg-generate": {
7
+ "executor": "nx:run-commands",
8
+ "cache": true,
9
+ "inputs": [
10
+ "{workspaceRoot}/contracts/openapi/presentation/__APP_NAME__/openapi.json",
11
+ "{projectRoot}/ftg.config.json"
12
+ ],
13
+ "outputs": ["{projectRoot}/src/api/generated/catalog"],
14
+ "options": {
15
+ "command": "pnpm exec ftg generate --config apps/__APP_NAME__/ftg.config.json --json"
16
+ }
17
+ },
18
+ "generated-check": {
19
+ "executor": "nx:run-commands",
20
+ "cache": false,
21
+ "options": {
22
+ "command": "pnpm exec ftg check --config apps/__APP_NAME__/ftg.config.json --json"
23
+ }
24
+ },
25
+ "design-verify": {
26
+ "executor": "nx:run-commands",
27
+ "cache": false,
28
+ "options": {
29
+ "command": "pnpm exec mpfrontend design status --directory . --json"
30
+ }
31
+ },
32
+ "catalog-check": {
33
+ "executor": "nx:run-commands",
34
+ "options": {
35
+ "command": "pnpm exec mpfrontend catalog check --app apps/__APP_NAME__ --json"
36
+ }
37
+ },
38
+ "format": {
39
+ "executor": "nx:run-commands",
40
+ "cache": false,
41
+ "options": {
42
+ "command": "pnpm exec prettier --write apps/__APP_NAME__"
43
+ }
44
+ },
45
+ "format:check": {
46
+ "executor": "nx:run-commands",
47
+ "options": {
48
+ "command": "pnpm exec prettier --check apps/__APP_NAME__"
49
+ }
50
+ },
51
+ "lint": {
52
+ "executor": "nx:run-commands",
53
+ "options": {
54
+ "command": "pnpm exec eslint apps/__APP_NAME__"
55
+ }
56
+ },
57
+ "test": {
58
+ "executor": "nx:run-commands",
59
+ "options": {
60
+ "command": "node --import tsx --test \"apps/__APP_NAME__/src/**/*.test.ts\" \"apps/__APP_NAME__/src/**/*.test.tsx\"",
61
+ "env": {
62
+ "TSX_TSCONFIG_PATH": "apps/__APP_NAME__/tsconfig.json"
63
+ }
64
+ }
65
+ },
66
+ "build": {
67
+ "executor": "nx:run-commands",
68
+ "dependsOn": ["^build", "ftg-generate", "runtime-assets"],
69
+ "outputs": ["{projectRoot}/.next", "{projectRoot}/runtime-assets"],
70
+ "options": {
71
+ "cwd": "apps/__APP_NAME__",
72
+ "command": "pnpm exec next build . --webpack"
73
+ }
74
+ },
75
+ "typecheck": {
76
+ "executor": "nx:run-commands",
77
+ "dependsOn": ["build"],
78
+ "options": {
79
+ "command": "pnpm exec tsc -p apps/__APP_NAME__/tsconfig.json --noEmit"
80
+ }
81
+ },
82
+ "runtime-assets": {
83
+ "executor": "nx:run-commands",
84
+ "cache": true,
85
+ "inputs": [
86
+ "{projectRoot}/prepare-runtime.mjs",
87
+ "{projectRoot}/package.json",
88
+ "{workspaceRoot}/pnpm-lock.yaml"
89
+ ],
90
+ "outputs": ["{projectRoot}/runtime-assets"],
91
+ "options": {
92
+ "command": "node apps/__APP_NAME__/prepare-runtime.mjs"
93
+ }
94
+ },
95
+ "dev": {
96
+ "dependsOn": ["runtime-assets"],
97
+ "executor": "nx:run-commands",
98
+ "cache": false,
99
+ "options": {
100
+ "cwd": "apps/__APP_NAME__",
101
+ "command": "pnpm exec next dev . --webpack -p 4301"
102
+ }
103
+ },
104
+ "start": {
105
+ "executor": "nx:run-commands",
106
+ "cache": false,
107
+ "options": {
108
+ "cwd": "apps/__APP_NAME__",
109
+ "command": "pnpm exec next start . -p 4301"
110
+ }
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,153 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ createServer,
5
+ type IncomingHttpHeaders,
6
+ type IncomingMessage,
7
+ type ServerResponse,
8
+ } from 'node:http';
9
+ import { createHash } from 'node:crypto';
10
+ import { transferHandler } from './transfer';
11
+
12
+ const MB = 1024 * 1024;
13
+ const size = 8 * MB;
14
+ const file = Buffer.alloc(size);
15
+ for (let index = 0; index < size; index++) file[index] = index % 256;
16
+ const digest = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex');
17
+ const context = { params: Promise.resolve({ path: ['files', 'report'] }) };
18
+
19
+ /** The promise's value, or a failure after `ms`: a streaming gate fails the test instead of hanging the run. */
20
+ async function within<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
21
+ let timer: NodeJS.Timeout | undefined;
22
+ try {
23
+ return await Promise.race([
24
+ promise,
25
+ new Promise<never>((_, reject) => {
26
+ timer = setTimeout(() => reject(new assert.AssertionError({ message })), ms);
27
+ }),
28
+ ]);
29
+ } finally {
30
+ clearTimeout(timer);
31
+ }
32
+ }
33
+
34
+ /** A fake BFF on a loopback port, so that the relay is tested without a backend. */
35
+ async function fakeBff(handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>) {
36
+ const server = createServer((req, res) => {
37
+ handle(req, res).catch(() => res.destroy());
38
+ });
39
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
40
+ const { port } = server.address() as { port: number };
41
+ return {
42
+ origin: 'http://127.0.0.1:' + port,
43
+ close: () =>
44
+ new Promise<void>((resolve) => {
45
+ server.closeAllConnections();
46
+ server.close(() => resolve());
47
+ }),
48
+ };
49
+ }
50
+
51
+ test('a file of several megabytes goes to the BFF and back through the relay, as streams', { timeout: 60000 }, async () => {
52
+ let seen: IncomingHttpHeaders = {};
53
+ const received: Buffer[] = [];
54
+ let reportArrival = () => {};
55
+ const arrived = new Promise<void>((resolve) => {
56
+ reportArrival = resolve;
57
+ });
58
+ const bff = await fakeBff(async (req, res) => {
59
+ if (req.method === 'GET') {
60
+ res.writeHead(200, {
61
+ 'content-type': 'application/octet-stream',
62
+ 'content-length': String(size),
63
+ 'x-internal': 'not passed',
64
+ });
65
+ res.end(file);
66
+ return;
67
+ }
68
+ seen = req.headers;
69
+ for await (const chunk of req) {
70
+ received.push(chunk as Buffer);
71
+ reportArrival();
72
+ }
73
+ res.writeHead(201, { 'content-type': 'application/json', 'set-cookie': 'tracker=1' });
74
+ res.end('{"stored":true}');
75
+ });
76
+ try {
77
+ const handle = transferHandler(() => ({
78
+ bffOrigin: bff.origin,
79
+ basePath: '/app/transfer',
80
+ sessionCookie: 'app_session',
81
+ }));
82
+ // The client produces 1 MiB, then waits until the BFF has bytes: a relay that held the whole body
83
+ // would never let the upload finish.
84
+ let offset = 0;
85
+ const body = new ReadableStream<Uint8Array>(
86
+ {
87
+ async pull(controller) {
88
+ if (offset === MB)
89
+ await within(arrived, 10000, 'the BFF received no byte before the relay had the whole file');
90
+ if (offset >= size) {
91
+ controller.close();
92
+ return;
93
+ }
94
+ const chunk = file.subarray(offset, offset + 65536);
95
+ offset += chunk.length;
96
+ controller.enqueue(new Uint8Array(chunk));
97
+ },
98
+ },
99
+ { highWaterMark: 0 },
100
+ );
101
+ const upload = {
102
+ method: 'PUT',
103
+ body,
104
+ duplex: 'half',
105
+ headers: {
106
+ cookie: 'theme=dark; app_session=opaque',
107
+ origin: 'http://localhost',
108
+ 'x-csrf-token': 'token',
109
+ 'content-type': 'application/octet-stream',
110
+ 'content-length': String(size),
111
+ authorization: 'Bearer not-forwarded',
112
+ },
113
+ };
114
+ const uploaded = await within(
115
+ handle(new Request('http://localhost/api/transfer/files/report', upload as RequestInit), context),
116
+ 30000,
117
+ 'the upload did not finish',
118
+ );
119
+ assert.equal(uploaded.status, 201);
120
+ assert.equal(digest(Buffer.concat(received)), digest(file));
121
+ assert.equal(seen.cookie, 'app_session=opaque');
122
+ assert.equal(seen['x-csrf-token'], 'token');
123
+ assert.equal(seen.authorization, undefined);
124
+ assert.deepEqual(uploaded.headers.getSetCookie(), []);
125
+ const downloaded = await within(
126
+ handle(
127
+ new Request('http://localhost/api/transfer/files/report', {
128
+ headers: { cookie: 'app_session=opaque' },
129
+ }),
130
+ context,
131
+ ),
132
+ 10000,
133
+ 'the download did not start',
134
+ );
135
+ assert.equal(downloaded.status, 200);
136
+ const bytes = await within(downloaded.arrayBuffer(), 30000, 'the download did not finish');
137
+ assert.equal(digest(new Uint8Array(bytes)), digest(file));
138
+ assert.equal(downloaded.headers.get('content-length'), String(size));
139
+ assert.equal(downloaded.headers.get('x-internal'), null);
140
+ } finally {
141
+ await bff.close();
142
+ }
143
+ });
144
+
145
+ test('without a session cookie name the relay is not configured', async () => {
146
+ const handle = transferHandler(() => ({
147
+ bffOrigin: 'http://127.0.0.1:9',
148
+ basePath: '/app/transfer',
149
+ sessionCookie: '',
150
+ }));
151
+ const response = await handle(new Request('http://localhost/api/transfer/files/report'), context);
152
+ assert.equal(response.status, 503);
153
+ });