@starci/hfs 3.0.0 → 4.0.1
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/CHANGELOG.md +22 -0
- package/README.md +25 -21
- package/bin/hfs.mjs +59 -37
- package/lint/run.mjs +70 -39
- package/package.json +2 -2
- package/runtime/engine/admission.mjs +3 -3
- package/runtime/engine/ledger-db.mjs +2 -2
- package/runtime/engine/machine-db.mjs +90 -9
- package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
- package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/modules/kernel/failure-codes.yaml +23 -32
- package/runtime/scripts/checks/architecture/backend.mjs +1 -1
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-check.mjs +156 -141
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
- package/runtime/scripts/lib/hfs-rules/deps.mjs +4 -2
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
- package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/runtime/scripts/lib/safe-remove.mjs +95 -10
- package/scaffold/app.mjs +205 -0
- package/scaffold/service.mjs +26 -16
- package/sync/cli.mjs +1 -1
- package/sync/hygiene.mjs +11 -8
- package/sync/index.mjs +109 -111
- package/sync/managed.mjs +9 -8
- package/sync/sonar-key.mjs +20 -22
- package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
- package/templates/app/gitignore +6 -0
- package/templates/app/hooks/husky/pre-commit +25 -0
- package/templates/app/hooks/husky/pre-push +7 -0
- package/templates/app/package-scripts/package.json +22 -0
- package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
- package/templates/app/skeleton/.editorconfig +15 -0
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton/.nvmrc +1 -0
- package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
- package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton/README.md +36 -0
- package/templates/app/skeleton/scripts/codegen.mjs +4 -0
- package/templates/{fe → app}/tool-config/prettierignore +4 -1
- package/templates/be/skeleton/.sops.yaml +2 -0
- package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
- package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
- package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
- package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
- package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
- package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
- package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
- package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
- package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
- package/sync/skeleton.mjs +0 -76
- package/templates/be/gitignore +0 -2
- package/templates/be/hooks/husky/pre-commit +0 -13
- package/templates/be/hooks/husky/pre-push +0 -6
- package/templates/be/package-scripts/package.json +0 -19
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
- package/templates/be/tool-config/prettierignore +0 -8
- package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
- package/templates/fe/gitignore +0 -3
- package/templates/fe/hooks/husky/pre-commit +0 -16
- package/templates/fe/hooks/husky/pre-push +0 -5
- package/templates/fe/package-scripts/package.json +0 -13
- package/templates/fe/parts/api-client.ts +0 -44
- package/templates/fe/parts/api-outcome.ts +0 -7
- package/templates/fe/quality-config/sonar-project.properties +0 -8
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
- package/templates/fe/tool-config/prettierrc +0 -1
- /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
- /package/templates/{be → app}/starciwork.gitignore +0 -0
- /package/templates/{be → app}/tool-config/prettierrc +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
|
@@ -1 +1,6 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
|
+
@import "@heroui/styles/css";
|
|
3
|
+
@import "@starci/grammar/common.css";
|
|
4
|
+
|
|
5
|
+
/* The grammar's renderers spell their layout in Tailwind utilities inside node_modules, which Tailwind never scans by default. */
|
|
6
|
+
@source "../../../../../node_modules/@starci/grammar/dist";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Button, GrammarRoot, Heading, PageContainer, WorkspaceShell } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** Props for {@link FailureScreen}. */
|
|
4
|
+
type FailureScreenProps = {
|
|
5
|
+
/** What failed, said to the reader. */
|
|
6
|
+
readonly title: string
|
|
7
|
+
/** The label of the one recovery action. */
|
|
8
|
+
readonly retryLabel: string
|
|
9
|
+
/** Runs the recovery: the boundary re-renders what failed. */
|
|
10
|
+
readonly onRetry: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A whole-screen failure with its one recovery action: the drawing both error boundaries share. */
|
|
14
|
+
export const FailureScreen = (props: FailureScreenProps) => (
|
|
15
|
+
<GrammarRoot>
|
|
16
|
+
<WorkspaceShell
|
|
17
|
+
primaryLabel={props.title}
|
|
18
|
+
primary={
|
|
19
|
+
<PageContainer measure="reading">
|
|
20
|
+
<Heading level={1}>{props.title}</Heading>
|
|
21
|
+
<Button variant="danger" onPress={props.onRetry}>
|
|
22
|
+
{props.retryLabel}
|
|
23
|
+
</Button>
|
|
24
|
+
</PageContainer>
|
|
25
|
+
}
|
|
26
|
+
/>
|
|
27
|
+
</GrammarRoot>
|
|
28
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
import { notFound } from "next/navigation"
|
|
3
|
+
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
|
4
|
+
import { getMessages, getTranslations, setRequestLocale } from "next-intl/server"
|
|
5
|
+
import type { ReactNode } from "react"
|
|
6
|
+
import { siteUrl } from "@/modules/config"
|
|
7
|
+
import { routing } from "@/modules/i18n"
|
|
8
|
+
|
|
9
|
+
type LocaleShellProps = {
|
|
10
|
+
readonly lang: string
|
|
11
|
+
readonly children: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The document title and the absolute base of every metadata URL, for the language in the address. */
|
|
15
|
+
export const localeMetadata = async (lang: string): Promise<Metadata> => {
|
|
16
|
+
if (!hasLocale(routing.locales, lang)) notFound()
|
|
17
|
+
const t = await getTranslations({ locale: lang, namespace: "app" })
|
|
18
|
+
return { title: t("title"), metadataBase: new URL(siteUrl()) }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The document of one route language: `<html lang>` from the address and the catalog handed to client components. */
|
|
22
|
+
export const LocaleShell = async (props: LocaleShellProps) => {
|
|
23
|
+
if (!hasLocale(routing.locales, props.lang)) notFound()
|
|
24
|
+
setRequestLocale(props.lang)
|
|
25
|
+
const messages = await getMessages()
|
|
26
|
+
return (
|
|
27
|
+
<html lang={props.lang}>
|
|
28
|
+
<body className="min-h-dvh">
|
|
29
|
+
<NextIntlClientProvider locale={props.lang} messages={messages}>
|
|
30
|
+
{props.children}
|
|
31
|
+
</NextIntlClientProvider>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { FailureScreen } from "@/components/composites/FailureScreen"
|
|
2
|
+
|
|
3
|
+
/** Props for {@link ErrorPageBase}. */
|
|
4
|
+
export type ErrorPageBaseProps = {
|
|
5
|
+
/** Whole-screen situations this surface settles; the error page only ever shows the failure. */
|
|
6
|
+
readonly state: "failed"
|
|
7
|
+
/** The words the failure shows. */
|
|
8
|
+
readonly props: {
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly retryLabel: string
|
|
11
|
+
}
|
|
12
|
+
/** What the surface reports upward. */
|
|
13
|
+
readonly on: {
|
|
14
|
+
readonly retry: () => void
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Draw the failure of the locale segment with its one recovery action. */
|
|
19
|
+
export const ErrorPageBase = (props: ErrorPageBaseProps) => (
|
|
20
|
+
<FailureScreen title={props.props.title} retryLabel={props.props.retryLabel} onRetry={props.on.retry} />
|
|
21
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useTranslations } from "next-intl"
|
|
2
|
+
import { ErrorPageBase } from "./component"
|
|
3
|
+
|
|
4
|
+
/** The public props of the locale error page: the boundary hands it the retry. */
|
|
5
|
+
type ErrorPageProps = { readonly onRetry: () => void }
|
|
6
|
+
|
|
7
|
+
/** A render failure of the locale segment shows this instead of a blank page; retry re-renders the segment. */
|
|
8
|
+
export const ErrorPage = (props: ErrorPageProps) => {
|
|
9
|
+
const t = useTranslations("errors.page")
|
|
10
|
+
return (
|
|
11
|
+
<ErrorPageBase
|
|
12
|
+
state="failed"
|
|
13
|
+
props={{ title: t("title"), retryLabel: t("retry") }}
|
|
14
|
+
on={{ retry: props.onRetry }}
|
|
15
|
+
/>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FailureScreen } from "@/components/composites/FailureScreen"
|
|
2
|
+
|
|
3
|
+
/** Props for {@link GlobalErrorPageBase}. */
|
|
4
|
+
export type GlobalErrorPageBaseProps = {
|
|
5
|
+
/** Whole-screen situations this surface settles; the global error page only ever shows the failure. */
|
|
6
|
+
readonly state: "failed"
|
|
7
|
+
/** The language of the document and the words the failure shows. */
|
|
8
|
+
readonly props: {
|
|
9
|
+
readonly lang: string
|
|
10
|
+
readonly title: string
|
|
11
|
+
readonly retryLabel: string
|
|
12
|
+
}
|
|
13
|
+
/** What the surface reports upward. */
|
|
14
|
+
readonly on: {
|
|
15
|
+
readonly retry: () => void
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Draw the whole document, because this boundary replaces the root layout that would have drawn it. */
|
|
20
|
+
export const GlobalErrorPageBase = (props: GlobalErrorPageBaseProps) => (
|
|
21
|
+
<html lang={props.props.lang}>
|
|
22
|
+
<body>
|
|
23
|
+
<FailureScreen title={props.props.title} retryLabel={props.props.retryLabel} onRetry={props.on.retry} />
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
26
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { DEFAULT_LOCALE } from "@/modules/i18n"
|
|
2
|
+
import messages from "@/modules/i18n/messages/vi.json"
|
|
3
|
+
import { GlobalErrorPageBase } from "./component"
|
|
4
|
+
|
|
5
|
+
/** The public props of the last-resort error page: the boundary hands it the retry. */
|
|
6
|
+
type GlobalErrorPageProps = { readonly onRetry: () => void }
|
|
7
|
+
|
|
8
|
+
/** The last-resort boundary above the locale layout: it has no provider, so it reads the default catalog directly. */
|
|
9
|
+
export const GlobalErrorPage = (props: GlobalErrorPageProps) => (
|
|
10
|
+
<GlobalErrorPageBase
|
|
11
|
+
state="failed"
|
|
12
|
+
props={{ lang: DEFAULT_LOCALE, title: messages.errors.global.title, retryLabel: messages.errors.global.retry }}
|
|
13
|
+
on={{ retry: props.onRetry }}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { GrammarRoot, Heading, PageContainer, WorkspaceShell } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** Props for {@link HomePageBase}. */
|
|
4
|
+
export type HomePageBaseProps = {
|
|
5
|
+
/** Whole-screen situations this surface settles; the home page only ever shows its title. */
|
|
6
|
+
readonly state: "ready"
|
|
7
|
+
/** The words the page shows. */
|
|
8
|
+
readonly props: { readonly title: string }
|
|
9
|
+
/** What the surface reports upward; the page reports nothing. */
|
|
10
|
+
readonly on: Record<never, never>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Draw the front door of the app. */
|
|
14
|
+
export const HomePageBase = (props: HomePageBaseProps) => (
|
|
15
|
+
<GrammarRoot>
|
|
16
|
+
<WorkspaceShell
|
|
17
|
+
primaryLabel={props.props.title}
|
|
18
|
+
primary={
|
|
19
|
+
<PageContainer measure="reading">
|
|
20
|
+
<Heading level={1}>{props.props.title}</Heading>
|
|
21
|
+
</PageContainer>
|
|
22
|
+
}
|
|
23
|
+
/>
|
|
24
|
+
</GrammarRoot>
|
|
25
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
import { getTranslations } from "next-intl/server"
|
|
3
|
+
import { HomePageBase } from "./component"
|
|
4
|
+
|
|
5
|
+
/** The document title of the home page, from the catalog of the requested locale. */
|
|
6
|
+
export const homeMetadata = async (): Promise<Metadata> => {
|
|
7
|
+
const t = await getTranslations("home")
|
|
8
|
+
return { title: t("title") }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The first page of the app: a server component, so no catalog is shipped for it. */
|
|
12
|
+
export const HomePage = async () => {
|
|
13
|
+
const t = await getTranslations("home")
|
|
14
|
+
return <HomePageBase state="ready" props={{ title: t("title") }} on={{}} />
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { GrammarRoot, PageContainer, Text, WorkspaceShell } from "@starci/grammar/common"
|
|
2
|
+
|
|
3
|
+
/** Props for {@link LoadingPageBase}. */
|
|
4
|
+
export type LoadingPageBaseProps = {
|
|
5
|
+
/** Whole-screen situations this surface settles; the loading page only ever waits. */
|
|
6
|
+
readonly state: "loading"
|
|
7
|
+
/** The words the wait announces. */
|
|
8
|
+
readonly props: { readonly message: string }
|
|
9
|
+
/** What the surface reports upward; a wait reports nothing. */
|
|
10
|
+
readonly on: Record<never, never>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Draw the wait as one politely announced line. */
|
|
14
|
+
export const LoadingPageBase = (props: LoadingPageBaseProps) => (
|
|
15
|
+
<GrammarRoot>
|
|
16
|
+
<WorkspaceShell
|
|
17
|
+
primaryLabel={props.props.message}
|
|
18
|
+
primary={
|
|
19
|
+
<PageContainer measure="reading">
|
|
20
|
+
<Text live="polite" tone="muted">
|
|
21
|
+
{props.props.message}
|
|
22
|
+
</Text>
|
|
23
|
+
</PageContainer>
|
|
24
|
+
}
|
|
25
|
+
/>
|
|
26
|
+
</GrammarRoot>
|
|
27
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getTranslations } from "next-intl/server"
|
|
2
|
+
import { LoadingPageBase } from "./component"
|
|
3
|
+
|
|
4
|
+
/** Shown while a route of the locale segment resolves. */
|
|
5
|
+
export const LoadingPage = async () => {
|
|
6
|
+
const t = await getTranslations("loading")
|
|
7
|
+
return <LoadingPageBase state="loading" props={{ message: t("message") }} on={{}} />
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { GrammarRoot, Heading, PageContainer, TextAction, WorkspaceShell } from "@starci/grammar/common"
|
|
2
|
+
import { ROUTES } from "@/modules/routes"
|
|
3
|
+
|
|
4
|
+
/** Props for {@link NotFoundPageBase}. */
|
|
5
|
+
export type NotFoundPageBaseProps = {
|
|
6
|
+
/** Whole-screen situations this surface settles; the not-found page only ever says the page is missing. */
|
|
7
|
+
readonly state: "missing"
|
|
8
|
+
/** The words the page shows. */
|
|
9
|
+
readonly props: {
|
|
10
|
+
readonly title: string
|
|
11
|
+
readonly homeLabel: string
|
|
12
|
+
}
|
|
13
|
+
/** What the surface reports upward; the page reports nothing. */
|
|
14
|
+
readonly on: Record<never, never>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Draw the missing-page message and the way back to the front door. */
|
|
18
|
+
export const NotFoundPageBase = (props: NotFoundPageBaseProps) => (
|
|
19
|
+
<GrammarRoot>
|
|
20
|
+
<WorkspaceShell
|
|
21
|
+
primaryLabel={props.props.title}
|
|
22
|
+
primary={
|
|
23
|
+
<PageContainer measure="reading">
|
|
24
|
+
<Heading level={1}>{props.props.title}</Heading>
|
|
25
|
+
<TextAction appearance="inline" href={ROUTES.home}>
|
|
26
|
+
{props.props.homeLabel}
|
|
27
|
+
</TextAction>
|
|
28
|
+
</PageContainer>
|
|
29
|
+
}
|
|
30
|
+
/>
|
|
31
|
+
</GrammarRoot>
|
|
32
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { getTranslations } from "next-intl/server"
|
|
2
|
+
import { NotFoundPageBase } from "./component"
|
|
3
|
+
|
|
4
|
+
/** Shown when a route calls `notFound()`. */
|
|
5
|
+
export const NotFoundPage = async () => {
|
|
6
|
+
const t = await getTranslations("notFound")
|
|
7
|
+
return <NotFoundPageBase state="missing" props={{ title: t("title"), homeLabel: t("home") }} on={{}} />
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only reader of `process.env` in this app. A value is read when it is asked for, not when the file loads, and a
|
|
3
|
+
* missing value stops the caller with a named error: there is no localhost fallback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const required = (name: string, value: string | undefined): string => {
|
|
7
|
+
if (value === undefined || value === "") throw new Error(`${name} is not set`)
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The public origin of the app, the base of every absolute metadata URL; throws when `NEXT_PUBLIC_SITE_URL` is not set. */
|
|
12
|
+
export const siteUrl = (): string => required("NEXT_PUBLIC_SITE_URL", process.env.NEXT_PUBLIC_SITE_URL)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import createMiddleware from "next-intl/middleware"
|
|
2
|
-
import { routing } from "./modules/i18n
|
|
2
|
+
import { routing } from "./modules/i18n"
|
|
3
3
|
|
|
4
4
|
/** Negotiates the locale and redirects; the default locale (vi) is served without a prefix. */
|
|
5
5
|
export default createMiddleware(routing)
|
package/sync/skeleton.mjs
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// hfs sync --init: the first source tree of a new repository (entrypoint, platform config/logging/errors, the health
|
|
2
|
-
// endpoint; for a front end the next-intl [locale] shell with vi default, as-needed prefix and proxy.ts).
|
|
3
|
-
// Unlike the managed files it is written once and never overwritten: an existing file is skipped, so re-running --init
|
|
4
|
-
// on a repository that already grew its own source changes nothing.
|
|
5
|
-
//
|
|
6
|
-
// A front end's skeleton is the templates/fe/skeleton tree (the app shell, common to every repository) plus ONE of two
|
|
7
|
-
// trees for what a repository writes once: with one app the app keeps its own i18n stack and its own API client
|
|
8
|
-
// (templates/fe/skeleton-app); with two or more apps the stack and the client are written once, as the packages
|
|
9
|
-
// `packages/<project>-i18n` and `packages/<project>-api`, and each app keeps a thin adapter over them
|
|
10
|
-
// (templates/fe/skeleton-shared). The choice is the number of apps in hfs.json, nothing else.
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import { TEMPLATES_DIR, SyncError, render, validateHfs } from './index.mjs';
|
|
14
|
-
|
|
15
|
-
const APP_DIR = '__app__';
|
|
16
|
-
const FAMILY_DIR = '__family__';
|
|
17
|
-
/** The slots a multi-app front end must opt into in hfs.json before its shared packages are written. */
|
|
18
|
-
export const SHARED_PACKAGE_SLOTS = Object.freeze(['fe.package.i18n', 'fe.package.api']);
|
|
19
|
-
const pascal = name => name.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('');
|
|
20
|
-
|
|
21
|
-
function listFiles(dir, base = dir) {
|
|
22
|
-
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
23
|
-
const full = path.join(dir, entry.name);
|
|
24
|
-
return entry.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** The apps that get a skeleton: a back end's `api` apps, every front-end app. */
|
|
29
|
-
export const skeletonApps = hfs => hfs.apps.filter(app => hfs.profile === 'fe' || app.kind === 'api');
|
|
30
|
-
|
|
31
|
-
/** True when the repository writes its i18n stack and API client once, as packages: a front end with two or more apps. */
|
|
32
|
-
export const sharesPackages = hfs => hfs.profile === 'fe' && hfs.apps.length > 1;
|
|
33
|
-
|
|
34
|
-
/** The template directories of a repository, in order: the profile's skeleton, then (front end) the one-app or the shared-package tree. */
|
|
35
|
-
export const skeletonDirs = hfs => [path.join(TEMPLATES_DIR, hfs.profile, 'skeleton'), ...(hfs.profile === 'fe' ? [path.join(TEMPLATES_DIR, 'fe', sharesPackages(hfs) ? 'skeleton-shared' : 'skeleton-app')] : [])];
|
|
36
|
-
|
|
37
|
-
/** Every skeleton file for this repository: [{ path, content }], the shared ones once and the per-app ones per app. */
|
|
38
|
-
export function skeletonFiles(hfs, dirs = skeletonDirs(hfs)) {
|
|
39
|
-
validateHfs(hfs);
|
|
40
|
-
if (sharesPackages(hfs)) {
|
|
41
|
-
const missing = SHARED_PACKAGE_SLOTS.filter(id => !(hfs.optionalSlots ?? []).includes(id));
|
|
42
|
-
if (missing.length) throw new SyncError('HFS_SYNC_HFS_INVALID', `a front end with ${hfs.apps.length} apps writes its i18n stack and its API client once, as packages: list ${missing.join(' and ')} in hfs.json optionalSlots before hfs sync --init`);
|
|
43
|
-
}
|
|
44
|
-
const files = [];
|
|
45
|
-
for (const dir of dirs) {
|
|
46
|
-
if (!fs.existsSync(dir)) throw new SyncError('HFS_SYNC_SKELETON_MISSING', `no skeleton templates in ${path.relative(TEMPLATES_DIR, dir)}`);
|
|
47
|
-
for (const rel of listFiles(dir)) {
|
|
48
|
-
const source = fs.readFileSync(path.join(dir, rel), 'utf8').replace(/\r\n/g, '\n');
|
|
49
|
-
const once = { family: hfs.project, project: hfs.project };
|
|
50
|
-
if (!rel.includes(APP_DIR)) {
|
|
51
|
-
files.push({ path: rel.split(FAMILY_DIR).join(hfs.project), content: render(source, once) });
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
for (const app of skeletonApps(hfs)) {
|
|
55
|
-
files.push({ path: rel.split(APP_DIR).join(app.name), content: render(source, { ...once, app: app.name, appPascal: pascal(app.name) }) });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return files;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Writes the skeleton under `root`, skipping every file that exists: { created, skipped } path lists. */
|
|
63
|
-
export function initSkeleton(root, hfs) {
|
|
64
|
-
const created = [], skipped = [];
|
|
65
|
-
for (const file of skeletonFiles(hfs)) {
|
|
66
|
-
const target = path.join(root, file.path);
|
|
67
|
-
if (fs.existsSync(target)) {
|
|
68
|
-
skipped.push(file.path);
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
72
|
-
fs.writeFileSync(target, file.content);
|
|
73
|
-
created.push(file.path);
|
|
74
|
-
}
|
|
75
|
-
return { created, skipped };
|
|
76
|
-
}
|
package/templates/be/gitignore
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{{header}}
|
|
2
|
-
# Commit gate: the secrets guard and work hygiene of the staged files, types, lint and format of the staged files, the unit specs the staged files touch. Never integration, e2e or contract.
|
|
3
|
-
npx hfs work-hygiene
|
|
4
|
-
npm run typecheck
|
|
5
|
-
sources=$(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.mts' '*.cts' '*.js' '*.mjs' '*.cjs' || true)
|
|
6
|
-
if [ -n "$sources" ]; then
|
|
7
|
-
npx eslint $sources
|
|
8
|
-
npx prettier --check $sources
|
|
9
|
-
fi
|
|
10
|
-
specs=$(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' | grep -v -E '(^|/)src/tests/(world|integration|e2e|contract)/|\.(integration|e2e|contract)-spec\.ts$' || true)
|
|
11
|
-
if [ -n "$specs" ]; then
|
|
12
|
-
npm run test:affected -- --findRelatedTests $specs
|
|
13
|
-
fi
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"scripts": {
|
|
3
|
-
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
4
|
-
{{appScripts}}
|
|
5
|
-
"typecheck": "tsc -p tsconfig.json",
|
|
6
|
-
"typecheck:tests": "tsc -p src/tests/tsconfig.json",
|
|
7
|
-
"lint": "hfs lint",
|
|
8
|
-
"lint:fix": "hfs lint --fix",
|
|
9
|
-
"format": "prettier --write .",
|
|
10
|
-
"format:check": "prettier --check .",
|
|
11
|
-
"test": "jest --selectProjects unit --coverage",
|
|
12
|
-
"test:affected": "jest --selectProjects unit --passWithNoTests",
|
|
13
|
-
"test:integration": "npm run typecheck:tests && jest --selectProjects integration",
|
|
14
|
-
"test:e2e": "npm run typecheck:tests && jest --selectProjects e2e",
|
|
15
|
-
"test:contract": "npm run typecheck:tests && jest --selectProjects contract",
|
|
16
|
-
"test:stack": "starci-test-stack",
|
|
17
|
-
"contract:emit": "hfs emit-contracts"
|
|
18
|
-
}
|
|
19
|
-
}
|
|
File without changes
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import { injector } from "@modules/platform/composition"
|
|
2
|
-
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
-
import type { ServerOptions } from "./server.options"
|
|
4
|
-
|
|
5
|
-
/** Token under which the app module provides its {@link ServerOptions}. */
|
|
6
|
-
export const SERVER_OPTIONS: unique symbol = Symbol("platform.config.server-options")
|
|
7
|
-
|
|
8
|
-
/** Injects the server options of the app. Parameter type: ServerOptions. */
|
|
9
|
-
export const InjectServerOptions = (): TypedParameterDecorator<ServerOptions> => injector<ServerOptions>(SERVER_OPTIONS)
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { Catch, HttpException, HttpStatus } from "@nestjs/common"
|
|
2
|
-
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"
|
|
3
|
-
import type { Response } from "express"
|
|
4
|
-
import { InjectLogger } from "@modules/platform/logging"
|
|
5
|
-
import type { Logger } from "@modules/platform/logging"
|
|
6
|
-
import { ErrorsLogEvent } from "./errors.log-events"
|
|
7
|
-
|
|
8
|
-
@Catch()
|
|
9
|
-
/** The one filter of an app: logs every failure once and answers with a status and a code that reveal nothing else. */
|
|
10
|
-
export class ErrorFilter implements ExceptionFilter {
|
|
11
|
-
constructor(@InjectLogger() private readonly logger: Logger) {}
|
|
12
|
-
|
|
13
|
-
/** Answers the request; an unknown failure is a 500 whose body names no detail. */
|
|
14
|
-
catch(exception: unknown, host: ArgumentsHost): void {
|
|
15
|
-
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
|
|
16
|
-
this.logger.error(ErrorsLogEvent.RequestFailed, exception, { status })
|
|
17
|
-
const code = status === HttpStatus.INTERNAL_SERVER_ERROR ? "internal_error" : `http_${status}`
|
|
18
|
-
host.switchToHttp().getResponse<Response>().status(status).json({ code })
|
|
19
|
-
}
|
|
20
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
{{header}}
|
|
2
|
-
name: ci
|
|
3
|
-
|
|
4
|
-
on:
|
|
5
|
-
push:
|
|
6
|
-
branches: [main]
|
|
7
|
-
pull_request:
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
contents: read
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
ci:
|
|
14
|
-
runs-on: ubuntu-latest
|
|
15
|
-
env:
|
|
16
|
-
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v4
|
|
19
|
-
- uses: actions/setup-node@v4
|
|
20
|
-
with:
|
|
21
|
-
node-version: {{nodeMajor}}
|
|
22
|
-
cache: npm
|
|
23
|
-
- run: npm ci
|
|
24
|
-
- name: lint
|
|
25
|
-
run: npm run lint -- --sonar reports/lint.sonar.json
|
|
26
|
-
- name: format
|
|
27
|
-
run: npm run format:check
|
|
28
|
-
- name: typecheck
|
|
29
|
-
run: npm run typecheck
|
|
30
|
-
- name: build
|
|
31
|
-
run: npm run build
|
|
32
|
-
- uses: SonarSource/sonarqube-scan-action@v7
|
|
33
|
-
if: ${{ !cancelled() && env.SONAR_TOKEN != '' }}
|
|
34
|
-
env:
|
|
35
|
-
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
|
36
|
-
- uses: SonarSource/sonarqube-quality-gate-action@v1
|
|
37
|
-
if: ${{ !cancelled() && env.SONAR_TOKEN != '' }}
|
|
38
|
-
timeout-minutes: 10
|
|
39
|
-
env:
|
|
40
|
-
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
package/templates/fe/gitignore
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
{{header}}
|
|
2
|
-
# Commit gate: the secrets guard, then types, lint and format of the staged files.
|
|
3
|
-
npx hfs work-hygiene
|
|
4
|
-
npm run typecheck
|
|
5
|
-
sources=$(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.tsx' '*.mts' '*.cts' '*.js' '*.jsx' '*.mjs' '*.cjs' || true)
|
|
6
|
-
if [ -n "$sources" ]; then
|
|
7
|
-
npx eslint --max-warnings=0 --no-warn-ignored $sources
|
|
8
|
-
fi
|
|
9
|
-
styles=$(git diff --cached --name-only --diff-filter=ACMR -- '*.css' || true)
|
|
10
|
-
if [ -n "$styles" ]; then
|
|
11
|
-
npx stylelint $styles
|
|
12
|
-
fi
|
|
13
|
-
formatted=$(git diff --cached --name-only --diff-filter=ACMR || true)
|
|
14
|
-
if [ -n "$formatted" ]; then
|
|
15
|
-
npx prettier --check --ignore-unknown $formatted
|
|
16
|
-
fi
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"scripts": {
|
|
3
|
-
"prepare": "husky",
|
|
4
|
-
{{appScripts}}
|
|
5
|
-
"codegen": "npm run codegen --workspaces --if-present",
|
|
6
|
-
"build": "npm run build --workspaces --if-present",
|
|
7
|
-
"typecheck": "npm run typecheck --workspaces --if-present",
|
|
8
|
-
"lint": "npm run codegen --silent && hfs lint --stylelint \"{{styleGlob}}\"",
|
|
9
|
-
"lint:fix": "npm run codegen --silent && hfs lint --fix --stylelint \"{{styleGlob}}\"",
|
|
10
|
-
"format": "prettier --write .",
|
|
11
|
-
"format:check": "prettier --check ."
|
|
12
|
-
}
|
|
13
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { Outcome } from "./outcome"
|
|
2
|
-
|
|
3
|
-
/** How long a request waits before it is abandoned and answered `unavailable`. */
|
|
4
|
-
const REQUEST_TIMEOUT_MS = 8000
|
|
5
|
-
|
|
6
|
-
/** One request of the client: the caller's own signal is joined with the timeout. */
|
|
7
|
-
export interface ClientRequest {
|
|
8
|
-
readonly url: string
|
|
9
|
-
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
|
10
|
-
readonly body?: unknown
|
|
11
|
-
readonly signal?: AbortSignal
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** The status of a response as an Outcome kind; 401 and 403 are `refused`, nothing collapses into `null`. */
|
|
15
|
-
const toOutcome = async (response: Response): Promise<Outcome<unknown>> => {
|
|
16
|
-
if (response.status === 401 || response.status === 403) return { kind: "refused" }
|
|
17
|
-
if (response.status === 404) return { kind: "not-found" }
|
|
18
|
-
if (response.status === 400 || response.status === 422) return { kind: "invalid" }
|
|
19
|
-
if (!response.ok) return { kind: "unavailable" }
|
|
20
|
-
try {
|
|
21
|
-
return { kind: "ok", data: (await response.json()) as unknown }
|
|
22
|
-
} catch {
|
|
23
|
-
return { kind: "unavailable" }
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The repository's one fetch. It always carries a timeout signal, never throws, and answers an Outcome; the body is
|
|
29
|
-
* `unknown` until a generated wire type narrows it.
|
|
30
|
-
*/
|
|
31
|
-
export const request = async ({ url, method = "GET", body, signal }: ClientRequest): Promise<Outcome<unknown>> => {
|
|
32
|
-
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
33
|
-
try {
|
|
34
|
-
const response = await fetch(url, {
|
|
35
|
-
method,
|
|
36
|
-
headers: { accept: "application/json", ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
37
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
38
|
-
signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
|
|
39
|
-
})
|
|
40
|
-
return await toOutcome(response)
|
|
41
|
-
} catch {
|
|
42
|
-
return { kind: "unavailable" }
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/** The one result every read of the backend returns: a status is a kind, never a thrown error and never `null`. */
|
|
2
|
-
export type Outcome<T> =
|
|
3
|
-
| { readonly kind: "ok"; readonly data: T }
|
|
4
|
-
| { readonly kind: "refused" }
|
|
5
|
-
| { readonly kind: "invalid" }
|
|
6
|
-
| { readonly kind: "not-found" }
|
|
7
|
-
| { readonly kind: "unavailable" }
|