@mpfrontend/nx-plugin 0.0.0-stage → 0.1.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.
- package/LICENSE +202 -0
- package/README.md +87 -2
- package/dist/feature-generator.d.ts +7 -0
- package/dist/feature-generator.js +9 -0
- package/dist/feature-generator.js.map +1 -0
- package/dist/generator.d.ts +6 -0
- package/dist/generator.js +14 -0
- package/dist/generator.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/nx-tree.d.ts +5 -0
- package/dist/nx-tree.js +12 -0
- package/dist/nx-tree.js.map +1 -0
- package/dist/package-generator.d.ts +8 -0
- package/dist/package-generator.js +9 -0
- package/dist/package-generator.js.map +1 -0
- package/dist/route-generator.d.ts +8 -0
- package/dist/route-generator.js +9 -0
- package/dist/route-generator.js.map +1 -0
- package/dist/scaffold.d.ts +96 -0
- package/dist/scaffold.js +227 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/templates.d.ts +21 -0
- package/dist/templates.js +83 -0
- package/dist/templates.js.map +1 -0
- package/dist/workspace.d.ts +41 -0
- package/dist/workspace.js +213 -0
- package/dist/workspace.js.map +1 -0
- package/generators.json +24 -0
- package/package.json +37 -3
- package/schema.json +18 -0
- package/schemas/feature-schema.json +25 -0
- package/schemas/package-schema.json +28 -0
- package/schemas/route-schema.json +31 -0
- package/templates/feature/list/api/server/__resource__.ts.template +24 -0
- package/templates/feature/list/app/api/__resource__/route.ts.template +24 -0
- package/templates/feature/list/entities/__resource__/index.ts.template +12 -0
- package/templates/feature/list/entities/__resource__/model/__resource__-list.test.ts.template +23 -0
- package/templates/feature/list/entities/__resource__/model/__resource__-list.ts.template +24 -0
- package/templates/feature/list/entities/__resource__/model/__resource__.test.ts.template +17 -0
- package/templates/feature/list/entities/__resource__/model/__resource__.ts.template +19 -0
- package/templates/feature/list/features/__feature__/api/__feature__-read.ts.template +12 -0
- package/templates/feature/list/features/__feature__/api/__feature__.ts.template +28 -0
- package/templates/feature/list/features/__feature__/hooks/use-__feature__-list.ts.template +22 -0
- package/templates/feature/list/features/__feature__/index.ts.template +3 -0
- package/templates/feature/list/features/__feature__/model/__feature__-fields.ts.template +4 -0
- package/templates/feature/list/features/__feature__/model/overrides/index.ts.template +3 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-detail-screen.tsx.template +16 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-detail.test.tsx.template +28 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-detail.tsx.template +44 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-list.tsx.template +75 -0
- package/templates/feature/list/features/__feature__/ui/__feature__-screen.tsx.template +31 -0
- package/templates/feature/list/features/__feature__/utils/__feature__-href.test.ts.template +7 -0
- package/templates/feature/list/features/__feature__/utils/__feature__-href.ts.template +4 -0
- package/templates/feature/screen/features/__feature__/api/__feature__.ts.template +13 -0
- package/templates/feature/screen/features/__feature__/hooks/use-__feature__.ts.template +27 -0
- package/templates/feature/screen/features/__feature__/index.ts.template +3 -0
- package/templates/feature/screen/features/__feature__/model/__feature__.test.ts.template +12 -0
- package/templates/feature/screen/features/__feature__/model/__feature__.ts.template +12 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-panel.tsx.template +22 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-screen.tsx.template +15 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-view.test.tsx.template +25 -0
- package/templates/feature/screen/features/__feature__/ui/__feature__-view.tsx.template +38 -0
- package/templates/feature/screen/features/__feature__/utils/__feature__.test.ts.template +9 -0
- package/templates/feature/screen/features/__feature__/utils/__feature__.ts.template +6 -0
- package/templates/next/README.md.template +10 -0
- package/templates/next/docs/api-contracts.md.template +22 -0
- package/templates/next/docs/overview.md.template +67 -0
- package/templates/next/eslint.config.mjs.template +4 -0
- package/templates/next/ftg.config.json.template +11 -0
- package/templates/next/next-env.d.ts.template +2 -0
- package/templates/next/next.config.mjs.template +9 -0
- package/templates/next/package.json.template +27 -0
- package/templates/next/postcss.config.mjs.template +2 -0
- package/templates/next/prepare-runtime.mjs.template +11 -0
- package/templates/next/project.json.template +113 -0
- package/templates/next/src/api/server/transfer.test.ts.template +153 -0
- package/templates/next/src/api/server/transfer.ts.template +25 -0
- package/templates/next/src/app/api/access/route.ts.template +6 -0
- package/templates/next/src/app/api/docs/assets/[file]/route.ts.template +23 -0
- package/templates/next/src/app/api/docs/route.ts.template +8 -0
- package/templates/next/src/app/api/openapi/route.ts.template +12 -0
- package/templates/next/src/app/api/transfer/[...path]/route.ts.template +22 -0
- package/templates/next/src/app/components/page.tsx.template +32 -0
- package/templates/next/src/app/globals.css.template +32 -0
- package/templates/next/src/app/layout.tsx.template +53 -0
- package/templates/next/src/app/page.tsx.template +6 -0
- package/templates/next/src/app/providers.tsx.template +31 -0
- package/templates/next/src/config/access.ts.template +21 -0
- package/templates/next/src/config/app.ts.template +33 -0
- package/templates/next/src/config/server.ts.template +13 -0
- package/templates/next/src/features/preferences/api/preference-cookie.ts.template +14 -0
- package/templates/next/src/features/preferences/hooks/use-preferences.ts.template +30 -0
- package/templates/next/src/features/preferences/index.ts.template +3 -0
- package/templates/next/src/features/preferences/model/messages/en.ts.template +10 -0
- package/templates/next/src/features/preferences/model/messages.ts.template +13 -0
- package/templates/next/src/features/preferences/model/preferences.test.ts.template +32 -0
- package/templates/next/src/features/preferences/model/preferences.ts.template +22 -0
- package/templates/next/src/features/preferences/ui/preference-controls.tsx.template +50 -0
- package/templates/next/src/i18n/index.ts.template +13 -0
- package/templates/next/src/i18n/messages/en.ts.template +18 -0
- package/templates/next/src/i18n/server.ts.template +8 -0
- package/templates/next/src/theme/config.test.ts.template +9 -0
- package/templates/next/src/theme/config.ts.template +7 -0
- package/templates/next/src/theme/theme.css.template +8 -0
- package/templates/next/tsconfig.json.template +11 -0
- package/templates/package/README.md.template +10 -0
- package/templates/package/eslint.config.mjs.template +4 -0
- package/templates/package/package.json.template +15 -0
- package/templates/package/project.json.template +51 -0
- package/templates/package/src/index.ts.template +2 -0
- package/templates/package/src/package-name.test.ts.template +7 -0
- package/templates/package/src/package-name.ts.template +2 -0
- package/templates/package/tsconfig.json.template +7 -0
- 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,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,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,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": "0.1.0",
|
|
8
|
+
"@mpfrontend/access-react": "0.1.0",
|
|
9
|
+
"@mpfrontend/app-layout": "0.1.0",
|
|
10
|
+
"@mpfrontend/data-react": "0.1.0",
|
|
11
|
+
"@mpfrontend/ftg-core": "0.1.0",
|
|
12
|
+
"@mpfrontend/i18n": "0.1.0",
|
|
13
|
+
"@mpfrontend/presentation-server": "0.1.0",
|
|
14
|
+
"@mpfrontend/tokens": "0.1.0",
|
|
15
|
+
"@mpfrontend/ui": "0.1.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,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
|
+
});
|