@vendure-platform/create 0.5.1 → 1.0.0-major.202609160652.120.4
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/README.md +48 -36
- package/dist/add-to-existing.d.ts +20 -14
- package/dist/add-to-existing.js +1 -1
- package/dist/assets/demo-data/assets/branding/logo.svg +4 -0
- package/dist/assets/demo-data/assets/images/absorbent-pad.webp +0 -0
- package/dist/assets/demo-data/assets/images/adjustable-wrench.webp +0 -0
- package/dist/assets/demo-data/assets/images/anchor-bolt.webp +0 -0
- package/dist/assets/demo-data/assets/images/angle-grinder.webp +0 -0
- package/dist/assets/demo-data/assets/images/barrier-tape.webp +0 -0
- package/dist/assets/demo-data/assets/images/bench-grinder.webp +0 -0
- package/dist/assets/demo-data/assets/images/bracket.webp +0 -0
- package/dist/assets/demo-data/assets/images/broom.webp +0 -0
- package/dist/assets/demo-data/assets/images/bucket.webp +0 -0
- package/dist/assets/demo-data/assets/images/cable-gland.webp +0 -0
- package/dist/assets/demo-data/assets/images/cable-tie.webp +0 -0
- package/dist/assets/demo-data/assets/images/cable-tray.webp +0 -0
- package/dist/assets/demo-data/assets/images/cable.webp +0 -0
- package/dist/assets/demo-data/assets/images/chimney-base-kit.webp +0 -0
- package/dist/assets/demo-data/assets/images/chimney-cowl-bird-guard.webp +0 -0
- package/dist/assets/demo-data/assets/images/chimney-cowl-open.webp +0 -0
- package/dist/assets/demo-data/assets/images/chimney-storm-collar.webp +0 -0
- package/dist/assets/demo-data/assets/images/chimney-wall-bracket.webp +0 -0
- package/dist/assets/demo-data/assets/images/circular-saw.webp +0 -0
- package/dist/assets/demo-data/assets/images/cleaning-spray.webp +0 -0
- package/dist/assets/demo-data/assets/images/conduit.webp +0 -0
- package/dist/assets/demo-data/assets/images/cordless-drill.webp +0 -0
- package/dist/assets/demo-data/assets/images/drill-bits.webp +0 -0
- package/dist/assets/demo-data/assets/images/dust-mask.webp +0 -0
- package/dist/assets/demo-data/assets/images/ear-defenders.webp +0 -0
- package/dist/assets/demo-data/assets/images/ear-plugs.webp +0 -0
- package/dist/assets/demo-data/assets/images/extension-lead.webp +0 -0
- package/dist/assets/demo-data/assets/images/eye-wash-station.webp +0 -0
- package/dist/assets/demo-data/assets/images/face-shield.webp +0 -0
- package/dist/assets/demo-data/assets/images/file-set.webp +0 -0
- package/dist/assets/demo-data/assets/images/first-aid-kit.webp +0 -0
- package/dist/assets/demo-data/assets/images/floor-sign.webp +0 -0
- package/dist/assets/demo-data/assets/images/flue-bend-45.webp +0 -0
- package/dist/assets/demo-data/assets/images/flue-bend-90.webp +0 -0
- package/dist/assets/demo-data/assets/images/flue-section-1000mm.webp +0 -0
- package/dist/assets/demo-data/assets/images/flue-section-250mm.webp +0 -0
- package/dist/assets/demo-data/assets/images/flue-section-500mm.webp +0 -0
- package/dist/assets/demo-data/assets/images/fuse.webp +0 -0
- package/dist/assets/demo-data/assets/images/glove-dispenser.webp +0 -0
- package/dist/assets/demo-data/assets/images/grinding-disc.webp +0 -0
- package/dist/assets/demo-data/assets/images/hacksaw.webp +0 -0
- package/dist/assets/demo-data/assets/images/hammer.webp +0 -0
- package/dist/assets/demo-data/assets/images/hand-sanitiser.webp +0 -0
- package/dist/assets/demo-data/assets/images/head-torch.webp +0 -0
- package/dist/assets/demo-data/assets/images/heat-gun.webp +0 -0
- package/dist/assets/demo-data/assets/images/hex-bolt.webp +0 -0
- package/dist/assets/demo-data/assets/images/hex-key-set.webp +0 -0
- package/dist/assets/demo-data/assets/images/hex-nut.webp +0 -0
- package/dist/assets/demo-data/assets/images/hi-vis-jacket.webp +0 -0
- package/dist/assets/demo-data/assets/images/hi-vis-vest.webp +0 -0
- package/dist/assets/demo-data/assets/images/hose-clamp.webp +0 -0
- package/dist/assets/demo-data/assets/images/impact-driver.webp +0 -0
- package/dist/assets/demo-data/assets/images/jigsaw.webp +0 -0
- package/dist/assets/demo-data/assets/images/junction-box.webp +0 -0
- package/dist/assets/demo-data/assets/images/knee-pads.webp +0 -0
- package/dist/assets/demo-data/assets/images/machine-screw.webp +0 -0
- package/dist/assets/demo-data/assets/images/made-to-order-configurable-chimney.webp +0 -0
- package/dist/assets/demo-data/assets/images/mallet.webp +0 -0
- package/dist/assets/demo-data/assets/images/mcb.webp +0 -0
- package/dist/assets/demo-data/assets/images/mop.webp +0 -0
- package/dist/assets/demo-data/assets/images/paper-towels.webp +0 -0
- package/dist/assets/demo-data/assets/images/pipe-wrench.webp +0 -0
- package/dist/assets/demo-data/assets/images/pliers-set.webp +0 -0
- package/dist/assets/demo-data/assets/images/pry-bar.webp +0 -0
- package/dist/assets/demo-data/assets/images/rcd.webp +0 -0
- package/dist/assets/demo-data/assets/images/reciprocating-saw.webp +0 -0
- package/dist/assets/demo-data/assets/images/rivet.webp +0 -0
- package/dist/assets/demo-data/assets/images/roof-flashing-high-pitch.webp +0 -0
- package/dist/assets/demo-data/assets/images/roof-flashing-low-pitch.webp +0 -0
- package/dist/assets/demo-data/assets/images/safety-boots.webp +0 -0
- package/dist/assets/demo-data/assets/images/safety-glasses.webp +0 -0
- package/dist/assets/demo-data/assets/images/safety-harness.webp +0 -0
- package/dist/assets/demo-data/assets/images/safety-helmet.webp +0 -0
- package/dist/assets/demo-data/assets/images/safety-sign.webp +0 -0
- package/dist/assets/demo-data/assets/images/sanding-disc.webp +0 -0
- package/dist/assets/demo-data/assets/images/saw-blade.webp +0 -0
- package/dist/assets/demo-data/assets/images/screwdriver-set.webp +0 -0
- package/dist/assets/demo-data/assets/images/screwdriver.webp +0 -0
- package/dist/assets/demo-data/assets/images/self-tapping-screw.webp +0 -0
- package/dist/assets/demo-data/assets/images/socket-set.webp +0 -0
- package/dist/assets/demo-data/assets/images/socket.webp +0 -0
- package/dist/assets/demo-data/assets/images/spanner-set.webp +0 -0
- package/dist/assets/demo-data/assets/images/spanner.webp +0 -0
- package/dist/assets/demo-data/assets/images/spill-kit.webp +0 -0
- package/dist/assets/demo-data/assets/images/spirit-level.webp +0 -0
- package/dist/assets/demo-data/assets/images/switch.webp +0 -0
- package/dist/assets/demo-data/assets/images/tape-measure.webp +0 -0
- package/dist/assets/demo-data/assets/images/terminal-block.webp +0 -0
- package/dist/assets/demo-data/assets/images/threaded-rod.webp +0 -0
- package/dist/assets/demo-data/assets/images/torque-wrench.webp +0 -0
- package/dist/assets/demo-data/assets/images/utility-knife.webp +0 -0
- package/dist/assets/demo-data/assets/images/wall-pass-sleeve.webp +0 -0
- package/dist/assets/demo-data/assets/images/wall-plug.webp +0 -0
- package/dist/assets/demo-data/assets/images/washer.webp +0 -0
- package/dist/assets/demo-data/assets/images/waste-bin.webp +0 -0
- package/dist/assets/demo-data/assets/images/wire-connector.webp +0 -0
- package/dist/assets/demo-data/assets/images/wire-cutters.webp +0 -0
- package/dist/assets/demo-data/assets/images/wood-screw.webp +0 -0
- package/dist/assets/demo-data/assets/images/work-gloves.webp +0 -0
- package/dist/assets/demo-data/assets/images/work-light.webp +0 -0
- package/dist/assets/demo-data/assets/initial-data.json +667 -0
- package/dist/assets/demo-data/assets/products.csv +779 -0
- package/dist/assets/storefront/.env.example +7 -0
- package/dist/assets/storefront/next-env.d.ts +2 -1
- package/dist/assets/storefront/next.config.ts +14 -13
- package/dist/assets/storefront/package.json +3 -2
- package/dist/assets/storefront/src/app/[locale]/account/addresses/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/approvals/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/company/business-units/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/company/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/company/payment-methods/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/company/roles/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/company/users/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/profile/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/quick-order/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/account/quotes/[id]/page.tsx +10 -6
- package/dist/assets/storefront/src/app/[locale]/cart/order-summary.tsx +8 -5
- package/dist/assets/storefront/src/app/[locale]/cart/promotion-code.tsx +9 -6
- package/dist/assets/storefront/src/app/[locale]/checkout/page.tsx +2 -0
- package/dist/assets/storefront/src/app/[locale]/layout.tsx +3 -3
- package/dist/assets/storefront/src/app/[locale]/order-confirmation/[code]/order-confirmation.tsx +2 -60
- package/dist/assets/storefront/src/components/commerce/product-card-list.tsx +4 -2
- package/dist/assets/storefront/src/components/commerce/product-card.tsx +4 -2
- package/dist/assets/storefront/src/components/commerce/quote-action-buttons.tsx +31 -14
- package/dist/assets/storefront/src/config/capabilities.ts +49 -0
- package/dist/assets/storefront/src/config/scenario.ts +10 -0
- package/dist/assets/storefront/src/graphql-env.d.ts +47 -41
- package/dist/assets/storefront/src/i18n/server.ts +33 -5
- package/dist/assets/storefront/src/lib/backend-health.ts +11 -3
- package/dist/assets/storefront/src/lib/vendure/api.ts +7 -4
- package/dist/assets/storefront/src/lib/vendure/capability-documents.ts +252 -0
- package/dist/assets/storefront/src/lib/vendure/capability-drift.ts +187 -0
- package/dist/assets/storefront/src/lib/vendure/capability-schema.ts +358 -0
- package/dist/assets/storefront/src/lib/vendure/capability-ui.ts +15 -0
- package/dist/assets/storefront/src/lib/vendure/mutations.ts +835 -532
- package/dist/assets/storefront/src/lib/vendure/queries.ts +862 -519
- package/dist/assets/storefront/src/proxy.ts +11 -0
- package/dist/assets/storefront/src/schema.graphql +337 -243
- package/dist/assets/templates/CLAUDE.md.hbs +1 -0
- package/dist/assets/templates/README.md.hbs +24 -1
- package/dist/assets/templates/docker-compose.yml.hbs +4 -0
- package/dist/assets/templates/env.hbs +34 -9
- package/dist/assets/templates/environment.d.ts.hbs +3 -0
- package/dist/assets/templates/gitignore.hbs +7 -4
- package/dist/assets/templates/index.ts.hbs +1 -1
- package/dist/assets/templates/migrate.ts.hbs +55 -0
- package/dist/assets/templates/npmrc.hbs +7 -2
- package/dist/assets/templates/platform-edition.ts.hbs +23 -0
- package/dist/assets/templates/populate.ts.hbs +3 -3
- package/dist/assets/templates/server-package.json.hbs +15 -8
- package/dist/assets/templates/vendure-config.ts.hbs +25 -2
- package/dist/assets/templates/vite.config.mts.hbs +5 -0
- package/dist/bin/run.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/console-onboarding.d.ts +44 -0
- package/dist/console-onboarding.js +1 -0
- package/dist/hub-projects.d.ts +48 -0
- package/dist/hub-projects.js +1 -0
- package/dist/plugin-registry.d.ts +65 -5
- package/dist/plugin-registry.js +1 -1
- package/dist/registry.d.ts +16 -13
- package/dist/registry.js +1 -1
- package/dist/scaffold-shared.js +1 -1
- package/dist/scaffold-storefront-only.js +1 -1
- package/dist/scaffolder.js +1 -1
- package/dist/storefront-copier.d.ts +9 -2
- package/dist/storefront-copier.js +1 -1
- package/dist/template-renderer.d.ts +31 -9
- package/dist/template-renderer.js +1 -1
- package/dist/types.d.ts +75 -24
- package/package.json +14 -2
- package/dist/assets/storefront/src/middleware.ts +0 -11
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
import { hasLocale } from 'next-intl';
|
|
2
|
-
import { getLocale } from 'next-intl/server';
|
|
2
|
+
import { getLocale, setRequestLocale } from 'next-intl/server';
|
|
3
|
+
import { locale as rootLocale } from 'next/root-params';
|
|
3
4
|
import { routing, type Locale } from './routing';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
|
-
* Resolve the current locale from
|
|
7
|
+
* Resolve the current locale from the `[locale]` root param, with validation.
|
|
8
|
+
* Falls back to defaultLocale if the segment isn't in routing config.
|
|
9
|
+
*
|
|
10
|
+
* This is the default on the server, including 'use cache' scopes. Root params
|
|
11
|
+
* carry no request state, so a component that resolves the locale this way
|
|
12
|
+
* still prerenders.
|
|
13
|
+
*/
|
|
14
|
+
export async function getRootLocale(): Promise<Locale> {
|
|
15
|
+
const loc = await rootLocale();
|
|
16
|
+
return hasLocale(routing.locales, loc) ? (loc as Locale) : routing.defaultLocale;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the current locale from request context, with validation.
|
|
7
21
|
* Falls back to defaultLocale if the resolved locale isn't in routing config.
|
|
8
22
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
23
|
+
* Only for scopes root params cannot reach: Client Components, Server Actions
|
|
24
|
+
* and Route Handlers, plus server code that already renders dynamically.
|
|
25
|
+
* Reading request state blocks prerendering, so a Server Component that calls
|
|
26
|
+
* this serves no static shell — call `getRootLocale()` there instead.
|
|
12
27
|
*/
|
|
13
28
|
export async function getRouteLocale(): Promise<Locale> {
|
|
14
29
|
const loc = await getLocale();
|
|
15
30
|
return hasLocale(routing.locales, loc) ? (loc as Locale) : routing.defaultLocale;
|
|
16
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pin the `[locale]` root param as next-intl's request locale.
|
|
35
|
+
*
|
|
36
|
+
* Call this before the first next-intl call in a Server Component that does not
|
|
37
|
+
* already pin the locale from `params`. Without it, `getTranslations()` resolves
|
|
38
|
+
* the locale through next-intl's request scope, which under `cacheComponents` is
|
|
39
|
+
* uncached request state: the route then fails `next build --debug-prerender`
|
|
40
|
+
* and serves no static shell.
|
|
41
|
+
*/
|
|
42
|
+
export async function pinRootLocale(): Promise<void> {
|
|
43
|
+
setRequestLocale(await getRootLocale());
|
|
44
|
+
}
|
|
@@ -10,12 +10,20 @@ const HEALTH_CHECK_TIMEOUT_MS = 3000;
|
|
|
10
10
|
* on Vercel) but every API call fails, leaving the user with a branded but
|
|
11
11
|
* empty shell. When this returns false we render a "Demo Paused" page instead.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* This is cacheLife('seconds') with one field changed: `expire` goes from 60 to
|
|
14
|
+
* 300. The preset's 60s expire is too short for Next 16.3's build-time cache,
|
|
15
|
+
* which drops the entry between the cache-warming phase and the final prerender
|
|
16
|
+
* phase, and every prerendered page that mounts this gate then logs an
|
|
17
|
+
* "Unexpected cache miss" during `next build`. Measured on 16.3: expire 300 is
|
|
18
|
+
* retained, 120 is not.
|
|
19
|
+
*
|
|
20
|
+
* `stale` and `revalidate` keep the preset's values, so how fast a stop/start of
|
|
21
|
+
* the backend is picked up is unchanged. `expire` only bounds how long a stale
|
|
22
|
+
* value may be served when revalidation never runs.
|
|
15
23
|
*/
|
|
16
24
|
export async function isBackendReachable(): Promise<boolean> {
|
|
17
25
|
'use cache';
|
|
18
|
-
cacheLife(
|
|
26
|
+
cacheLife({ stale: 30, revalidate: 1, expire: 300 });
|
|
19
27
|
|
|
20
28
|
const apiUrl = process.env.VENDURE_SHOP_API_URL || process.env.NEXT_PUBLIC_VENDURE_SHOP_API_URL;
|
|
21
29
|
if (!apiUrl) return true;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { RequestMiddleware } from '@vendure-platform/storefront-client';
|
|
2
2
|
import { createClient } from '@vendure-platform/storefront-client';
|
|
3
3
|
|
|
4
|
+
import { capabilityDriftMiddleware } from './capability-drift';
|
|
5
|
+
|
|
4
6
|
const DRAFT_MODE_SECRET = process.env.DRAFT_MODE_SECRET;
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -119,7 +121,7 @@ const currencyMiddleware: RequestMiddleware = async (req, next) => {
|
|
|
119
121
|
return next(req);
|
|
120
122
|
};
|
|
121
123
|
|
|
122
|
-
const middleware = [draftModeMiddleware, currencyMiddleware];
|
|
124
|
+
const middleware = [draftModeMiddleware, currencyMiddleware, capabilityDriftMiddleware];
|
|
123
125
|
|
|
124
126
|
export const client = createClient({
|
|
125
127
|
...sharedConfig,
|
|
@@ -138,12 +140,13 @@ export const client = createClient({
|
|
|
138
140
|
});
|
|
139
141
|
|
|
140
142
|
/**
|
|
141
|
-
* Client without providers
|
|
143
|
+
* Client without providers — for 'use cache' functions that fetch public data.
|
|
142
144
|
* Next.js 'use cache' forbids Dynamic APIs (cookies/headers), so this client must not
|
|
143
|
-
* access cookies, headers, or draftMode.
|
|
145
|
+
* access cookies, headers, or draftMode. Its one middleware, the capability drift
|
|
146
|
+
* check, reads only the response. Pass languageCode and currencyCode explicitly
|
|
144
147
|
* as query options from the calling function.
|
|
145
148
|
*/
|
|
146
|
-
export const publicClient = createClient({ ...sharedConfig });
|
|
149
|
+
export const publicClient = createClient({ ...sharedConfig, middleware: [capabilityDriftMiddleware] });
|
|
147
150
|
|
|
148
151
|
/**
|
|
149
152
|
* Pre-resolved context for use at cache boundaries where cookies() is not available.
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { hasCapability } from '@/config/capabilities';
|
|
2
|
+
import type {
|
|
3
|
+
DefinitionNode,
|
|
4
|
+
DocumentNode,
|
|
5
|
+
FieldNode,
|
|
6
|
+
FragmentDefinitionNode,
|
|
7
|
+
SelectionNode,
|
|
8
|
+
SelectionSetNode,
|
|
9
|
+
} from 'graphql';
|
|
10
|
+
import { visit } from 'graphql';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A selection to drop, written as the path of field names from the root of the
|
|
14
|
+
* operation or fragment definition that holds it: for example
|
|
15
|
+
* `['product', 'customFields', 'configuratorSystemCode']` in an operation, or
|
|
16
|
+
* `['isPublic']` inside `fragment ShoppingListFields`. Every path is tried on
|
|
17
|
+
* every definition, so a path only matches in the definitions it starts in.
|
|
18
|
+
*/
|
|
19
|
+
export type FieldPath = readonly string[];
|
|
20
|
+
|
|
21
|
+
export interface CapabilityDocumentRule {
|
|
22
|
+
capability: string;
|
|
23
|
+
paths: readonly FieldPath[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CapabilityDocumentRegistration<T extends DocumentNode = DocumentNode> {
|
|
27
|
+
name: string;
|
|
28
|
+
fullDocument: T;
|
|
29
|
+
rules: readonly CapabilityDocumentRule[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Capability-owned storefront documents, used by the schema composition tests
|
|
34
|
+
* and by the drift check in `capability-drift.ts`.
|
|
35
|
+
*/
|
|
36
|
+
export const STOREFRONT_CAPABILITY_DOCUMENTS: CapabilityDocumentRegistration[] = [];
|
|
37
|
+
|
|
38
|
+
function isField(selection: SelectionNode): selection is FieldNode {
|
|
39
|
+
return selection.kind === 'Field';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fieldName(field: FieldNode): string {
|
|
43
|
+
return field.name.value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function addOperationFallback<T extends DocumentNode>(document: T): T {
|
|
47
|
+
return {
|
|
48
|
+
...document,
|
|
49
|
+
definitions: document.definitions.map((definition) => {
|
|
50
|
+
if (definition.kind !== 'OperationDefinition') return definition;
|
|
51
|
+
if (
|
|
52
|
+
definition.selectionSet.selections.some(
|
|
53
|
+
(selection) => isField(selection) && fieldName(selection) === '__typename',
|
|
54
|
+
)
|
|
55
|
+
) {
|
|
56
|
+
return definition;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
...definition,
|
|
60
|
+
selectionSet: {
|
|
61
|
+
...definition.selectionSet,
|
|
62
|
+
selections: [
|
|
63
|
+
...definition.selectionSet.selections,
|
|
64
|
+
{ kind: 'Field', name: { kind: 'Name', value: '__typename' } },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}),
|
|
69
|
+
} as T;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fragmentSpreads(selectionSet: SelectionSetNode): string[] {
|
|
73
|
+
const names: string[] = [];
|
|
74
|
+
for (const selection of selectionSet.selections) {
|
|
75
|
+
if (selection.kind === 'FragmentSpread') {
|
|
76
|
+
names.push(selection.name.value);
|
|
77
|
+
} else if (selection.kind === 'Field' && selection.selectionSet) {
|
|
78
|
+
names.push(...fragmentSpreads(selection.selectionSet));
|
|
79
|
+
} else if (selection.kind === 'InlineFragment') {
|
|
80
|
+
names.push(...fragmentSpreads(selection.selectionSet));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function removeUnusedFragments(definitions: readonly DefinitionNode[]): readonly DefinitionNode[] {
|
|
87
|
+
const fragments = new Map<string, FragmentDefinitionNode>();
|
|
88
|
+
for (const definition of definitions) {
|
|
89
|
+
if (definition.kind === 'FragmentDefinition') {
|
|
90
|
+
fragments.set(definition.name.value, definition);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const used = new Set<string>();
|
|
95
|
+
const pending = definitions
|
|
96
|
+
.filter((definition) => definition.kind === 'OperationDefinition')
|
|
97
|
+
.flatMap((definition) => fragmentSpreads(definition.selectionSet));
|
|
98
|
+
while (pending.length > 0) {
|
|
99
|
+
const name = pending.pop()!;
|
|
100
|
+
if (used.has(name)) continue;
|
|
101
|
+
used.add(name);
|
|
102
|
+
const fragment = fragments.get(name);
|
|
103
|
+
if (fragment) pending.push(...fragmentSpreads(fragment.selectionSet));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return definitions.filter(
|
|
107
|
+
(definition) => definition.kind !== 'FragmentDefinition' || used.has(definition.name.value),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function removeUnusedVariables(definitions: readonly DefinitionNode[]): readonly DefinitionNode[] {
|
|
112
|
+
const used = new Set<string>();
|
|
113
|
+
visit({ kind: 'Document', definitions } as DocumentNode, {
|
|
114
|
+
VariableDefinition() {
|
|
115
|
+
return false;
|
|
116
|
+
},
|
|
117
|
+
Variable(node) {
|
|
118
|
+
used.add(node.name.value);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return definitions.map((definition) => {
|
|
123
|
+
if (definition.kind !== 'OperationDefinition' || !definition.variableDefinitions) {
|
|
124
|
+
return definition;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...definition,
|
|
128
|
+
variableDefinitions: definition.variableDefinitions.filter((variable) =>
|
|
129
|
+
used.has(variable.variable.name.value),
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Returns `selectionSet` with `path` removed, or `null` when removing it would
|
|
137
|
+
* leave an empty selection set — GraphQL has no syntax for one, so the caller
|
|
138
|
+
* drops the parent field instead.
|
|
139
|
+
*/
|
|
140
|
+
function stripPath(selectionSet: SelectionSetNode, path: FieldPath): SelectionSetNode | null {
|
|
141
|
+
const [head, ...rest] = path;
|
|
142
|
+
const selections: SelectionNode[] = [];
|
|
143
|
+
|
|
144
|
+
for (const selection of selectionSet.selections) {
|
|
145
|
+
if (!isField(selection) || fieldName(selection) !== head) {
|
|
146
|
+
selections.push(selection);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (rest.length === 0) {
|
|
150
|
+
// Leaf reached: drop it by not re-adding it.
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!selection.selectionSet) {
|
|
154
|
+
selections.push(selection);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const nested = stripPath(selection.selectionSet, rest);
|
|
158
|
+
if (nested) {
|
|
159
|
+
selections.push({ ...selection, selectionSet: nested });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return selections.length > 0 ? { ...selectionSet, selections } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Removes capability-gated selections from a GraphQL document.
|
|
168
|
+
*
|
|
169
|
+
* The server composes its schema from the capability modules the deployment is
|
|
170
|
+
* entitled to, so selecting a field owned by an absent module fails validation
|
|
171
|
+
* on every request. Rather than maintaining a hand-written copy of each
|
|
172
|
+
* document per composition, the full document stays the single typed source of
|
|
173
|
+
* truth and the selections a composition cannot serve are pruned from it here.
|
|
174
|
+
*
|
|
175
|
+
* The returned document keeps the input's TypeScript type: a pruned field is
|
|
176
|
+
* `undefined` at runtime, so consumers must treat capability-gated data as
|
|
177
|
+
* optional. That is the same contract they already need for a field the server
|
|
178
|
+
* may legitimately return as null.
|
|
179
|
+
*/
|
|
180
|
+
export function stripCapabilityFields<T extends DocumentNode>(document: T, paths: FieldPath[]): T {
|
|
181
|
+
if (paths.length === 0) {
|
|
182
|
+
return document;
|
|
183
|
+
}
|
|
184
|
+
let definitions = document.definitions;
|
|
185
|
+
for (const path of paths) {
|
|
186
|
+
definitions = definitions.map((definition) => {
|
|
187
|
+
if (definition.kind !== 'OperationDefinition' && definition.kind !== 'FragmentDefinition') {
|
|
188
|
+
return definition;
|
|
189
|
+
}
|
|
190
|
+
const stripped = stripPath(definition.selectionSet, path);
|
|
191
|
+
if (!stripped) {
|
|
192
|
+
// Every selection collapsed, which GraphQL cannot express. A
|
|
193
|
+
// prune list that empties a whole operation is a mistake in the
|
|
194
|
+
// capability mapping, not something to paper over by keeping
|
|
195
|
+
// the field that was supposed to be removed.
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Pruning "${path.join('.')}" would leave an empty selection set. ` +
|
|
198
|
+
'Split the document instead of pruning its only selection.',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return { ...definition, selectionSet: stripped };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const usedDefinitions = removeUnusedFragments(definitions);
|
|
205
|
+
return { ...document, definitions: removeUnusedVariables(usedDefinitions) } as T;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Convenience wrapper: prunes `paths` only when `granted` is false.
|
|
210
|
+
*/
|
|
211
|
+
export function whenGranted<T extends DocumentNode>(granted: boolean, document: T, paths: FieldPath[]): T {
|
|
212
|
+
return granted ? document : stripCapabilityFields(addOperationFallback(document), paths);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Registers and composes one typed storefront document from capability rules.
|
|
217
|
+
* The full typed document remains the source of truth. Missing grants prune
|
|
218
|
+
* only the selections that their modules own.
|
|
219
|
+
*/
|
|
220
|
+
export function composeCapabilityDocument<T extends DocumentNode>(
|
|
221
|
+
document: T,
|
|
222
|
+
rules: readonly CapabilityDocumentRule[],
|
|
223
|
+
): T {
|
|
224
|
+
const operation = document.definitions.find((definition) => definition.kind === 'OperationDefinition');
|
|
225
|
+
const name = operation?.name?.value;
|
|
226
|
+
if (!name) {
|
|
227
|
+
throw new Error('Capability-composed storefront documents must use a named operation.');
|
|
228
|
+
}
|
|
229
|
+
STOREFRONT_CAPABILITY_DOCUMENTS.push({ name, fullDocument: document, rules });
|
|
230
|
+
return composeCapabilityDocumentFor(document, rules, (capability) => hasCapability(capability));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Compose a registered document for an explicit grant set in tests. */
|
|
234
|
+
export function composeCapabilityDocumentFor<T extends DocumentNode>(
|
|
235
|
+
document: T,
|
|
236
|
+
rules: readonly CapabilityDocumentRule[],
|
|
237
|
+
granted: (capability: string) => boolean,
|
|
238
|
+
): T {
|
|
239
|
+
return rules.reduce(
|
|
240
|
+
(composed, rule) => whenGranted(granted(rule.capability), composed, [...rule.paths]),
|
|
241
|
+
document,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Registers a document owned by one capability. */
|
|
246
|
+
export function forCapability<T extends DocumentNode>(
|
|
247
|
+
capability: string,
|
|
248
|
+
document: T,
|
|
249
|
+
paths: readonly FieldPath[],
|
|
250
|
+
): T {
|
|
251
|
+
return composeCapabilityDocument(document, [{ capability, paths }]);
|
|
252
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VendureGraphQLError,
|
|
3
|
+
type GraphQLErrorEntry,
|
|
4
|
+
type RequestMiddleware,
|
|
5
|
+
} from '@vendure-platform/storefront-client';
|
|
6
|
+
import { getLocation, Kind, parse, visit, type ASTNode, type DocumentNode, type FieldNode } from 'graphql';
|
|
7
|
+
|
|
8
|
+
import { hasCapability } from '@/config/capabilities';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
STOREFRONT_CAPABILITY_DOCUMENTS,
|
|
12
|
+
type CapabilityDocumentRule,
|
|
13
|
+
type FieldPath,
|
|
14
|
+
} from './capability-documents';
|
|
15
|
+
|
|
16
|
+
/** The code Apollo Server gives every validation failure. It answers them with HTTP 400. */
|
|
17
|
+
const VALIDATION_FAILED = 'GRAPHQL_VALIDATION_FAILED';
|
|
18
|
+
|
|
19
|
+
/** A selection the server rejected that belongs to a capability this build claims. */
|
|
20
|
+
export interface CapabilityDrift {
|
|
21
|
+
capability: string;
|
|
22
|
+
/** From the root of the operation or fragment definition that holds the field. */
|
|
23
|
+
path: FieldPath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ErrorLocation {
|
|
27
|
+
line: number;
|
|
28
|
+
column: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Finds the rejected selections that belong to a capability the storefront was
|
|
33
|
+
* built with.
|
|
34
|
+
*
|
|
35
|
+
* Reads structured data only: the validation error code, the error locations,
|
|
36
|
+
* and the document that was sent. Each location is resolved to the field it
|
|
37
|
+
* points at, and that field's path is compared with the capability rules the
|
|
38
|
+
* document was registered with, the same way `stripCapabilityFields` applies
|
|
39
|
+
* them. An error that is not a validation failure, that points at a field whose
|
|
40
|
+
* owning capability the build does not claim, or that comes from an
|
|
41
|
+
* unregistered document is not drift.
|
|
42
|
+
*
|
|
43
|
+
* The check sees one direction only: a build that claims a capability the
|
|
44
|
+
* server does not compose. A build that claims fewer capabilities than the
|
|
45
|
+
* server composes sends valid documents and only hides features, so no response
|
|
46
|
+
* shows it.
|
|
47
|
+
*/
|
|
48
|
+
export function findCapabilityDrift(
|
|
49
|
+
query: string,
|
|
50
|
+
errors: readonly GraphQLErrorEntry[],
|
|
51
|
+
granted: (capability: string) => boolean,
|
|
52
|
+
): CapabilityDrift[] {
|
|
53
|
+
const locations = errors
|
|
54
|
+
.filter(isValidationFailure)
|
|
55
|
+
.flatMap((error) => error.locations as ErrorLocation[]);
|
|
56
|
+
if (locations.length === 0) return [];
|
|
57
|
+
|
|
58
|
+
let document: DocumentNode;
|
|
59
|
+
try {
|
|
60
|
+
document = parse(query);
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const operation = document.definitions.find(
|
|
65
|
+
(definition) => definition.kind === Kind.OPERATION_DEFINITION,
|
|
66
|
+
);
|
|
67
|
+
const registration = STOREFRONT_CAPABILITY_DOCUMENTS.find(
|
|
68
|
+
(candidate) => candidate.name === operation?.name?.value,
|
|
69
|
+
);
|
|
70
|
+
if (!registration) return [];
|
|
71
|
+
|
|
72
|
+
const drift: CapabilityDrift[] = [];
|
|
73
|
+
for (const path of rejectedFieldPaths(document, locations)) {
|
|
74
|
+
for (const capability of owners(registration.rules, path)) {
|
|
75
|
+
if (granted(capability)) drift.push({ capability, path });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return drift;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The capabilities whose most specific rule path contains `path`.
|
|
83
|
+
*
|
|
84
|
+
* Rules nest: `quote.requests` owns `myQuote` and `quote.negotiation` owns
|
|
85
|
+
* `myQuote.versions`, so a rejected `myQuote.versions` belongs to negotiation
|
|
86
|
+
* only.
|
|
87
|
+
*/
|
|
88
|
+
function owners(rules: readonly CapabilityDocumentRule[], path: FieldPath): string[] {
|
|
89
|
+
let longest = 0;
|
|
90
|
+
let found: string[] = [];
|
|
91
|
+
for (const rule of rules) {
|
|
92
|
+
for (const rulePath of rule.paths) {
|
|
93
|
+
if (rulePath.length < longest || !rulePath.every((name, index) => path[index] === name)) continue;
|
|
94
|
+
if (rulePath.length > longest) {
|
|
95
|
+
longest = rulePath.length;
|
|
96
|
+
found = [];
|
|
97
|
+
}
|
|
98
|
+
if (!found.includes(rule.capability)) found.push(rule.capability);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isValidationFailure(error: GraphQLErrorEntry): boolean {
|
|
105
|
+
const code = (error.extensions as { code?: unknown } | undefined)?.code;
|
|
106
|
+
return code === VALIDATION_FAILED && Array.isArray(error.locations);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The field-name path of every field that starts at one of `locations`. */
|
|
110
|
+
function rejectedFieldPaths(document: DocumentNode, locations: readonly ErrorLocation[]): FieldPath[] {
|
|
111
|
+
const paths: FieldPath[] = [];
|
|
112
|
+
visit(document, {
|
|
113
|
+
Field(node, _key, _parent, _path, ancestors) {
|
|
114
|
+
if (!node.loc) return;
|
|
115
|
+
const { line, column } = getLocation(node.loc.source, node.loc.start);
|
|
116
|
+
if (!locations.some((location) => location.line === line && location.column === column)) return;
|
|
117
|
+
const parents = (ancestors as ReadonlyArray<ASTNode | readonly ASTNode[]>).filter(
|
|
118
|
+
(ancestor): ancestor is FieldNode => 'kind' in ancestor && ancestor.kind === Kind.FIELD,
|
|
119
|
+
);
|
|
120
|
+
paths.push([...parents.map((field) => field.name.value), node.name.value]);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
return paths;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The Shop API rejected a selection because this storefront was built for a
|
|
128
|
+
* capability its server does not compose.
|
|
129
|
+
*
|
|
130
|
+
* Extends the error the client reports for GraphQL errors, and keeps the
|
|
131
|
+
* server's errors unchanged in `errors`.
|
|
132
|
+
*/
|
|
133
|
+
export class CapabilityDriftError extends VendureGraphQLError {
|
|
134
|
+
readonly drift: CapabilityDrift[];
|
|
135
|
+
|
|
136
|
+
constructor(errors: GraphQLErrorEntry[], response: Response, drift: CapabilityDrift[]) {
|
|
137
|
+
super(errors, response);
|
|
138
|
+
// The base class types `name` as its own literal, so a field override
|
|
139
|
+
// does not compile. Replace the value it set instead.
|
|
140
|
+
Object.defineProperty(this, 'name', { value: 'CapabilityDriftError' });
|
|
141
|
+
this.drift = drift;
|
|
142
|
+
const capabilities = [...new Set(drift.map((entry) => entry.capability))];
|
|
143
|
+
const fields = drift.map((entry) => entry.path.join('.'));
|
|
144
|
+
const subject = capabilities.length === 1 ? 'that capability' : 'those capabilities';
|
|
145
|
+
const serverErrors = errors.map((error) => error.message).join(', ');
|
|
146
|
+
this.message =
|
|
147
|
+
`Storefront capability drift: this storefront was built with NEXT_PUBLIC_VENDURE_CAPABILITIES ` +
|
|
148
|
+
`granting ${capabilities.join(', ')}, but the Shop API rejected ${fields.join(', ')}. ` +
|
|
149
|
+
`The server does not compose ${subject}. Rebuild the storefront with the capability keys ` +
|
|
150
|
+
`of the server's capability profile. Server errors: ${serverErrors}`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Reports a validation failure on a claimed capability's field as a
|
|
156
|
+
* {@link CapabilityDriftError}.
|
|
157
|
+
*
|
|
158
|
+
* Apollo Server answers a validation failure with HTTP 400, which the client
|
|
159
|
+
* otherwise reports only as "HTTP error 400". Only those responses are read
|
|
160
|
+
* here. Every other response passes through untouched.
|
|
161
|
+
*/
|
|
162
|
+
export const capabilityDriftMiddleware: RequestMiddleware = async (request, next) => {
|
|
163
|
+
const response = await next(request);
|
|
164
|
+
if (response.status !== 400) return response;
|
|
165
|
+
|
|
166
|
+
// A body can be read once. Middleware outside this one gets the same read,
|
|
167
|
+
// including its failure when the body is not JSON.
|
|
168
|
+
const body = response.json();
|
|
169
|
+
const payload = (await body.catch(() => undefined)) as { errors?: GraphQLErrorEntry[] } | undefined;
|
|
170
|
+
const query = sentQuery(request.body);
|
|
171
|
+
const errors = payload?.errors ?? [];
|
|
172
|
+
const drift = query === undefined ? [] : findCapabilityDrift(query, errors, hasCapability);
|
|
173
|
+
if (drift.length > 0) {
|
|
174
|
+
throw new CapabilityDriftError(errors, response.response, drift);
|
|
175
|
+
}
|
|
176
|
+
return { ...response, json: () => body };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** The GraphQL document in a request body, when the body is the JSON the client sends. */
|
|
180
|
+
function sentQuery(body: string): string | undefined {
|
|
181
|
+
try {
|
|
182
|
+
const { query } = JSON.parse(body) as { query?: unknown };
|
|
183
|
+
return typeof query === 'string' ? query : undefined;
|
|
184
|
+
} catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
}
|