@websolutespa/bom-next 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +11 -0
  3. package/dist/config.client-B8As5DY0.js +2 -0
  4. package/dist/config.edge-B8As5DY0.js +2 -0
  5. package/dist/config.server-B8As5DY0.js +2 -0
  6. package/dist/exports/client-xcFJNcG7.d.ts +979 -0
  7. package/dist/exports/client-xcFJNcG7.d.ts.map +1 -0
  8. package/dist/exports/client.js +5735 -0
  9. package/dist/exports/client.js.map +1 -0
  10. package/dist/exports/instrumentation-DBO6QH4d.d.ts +13 -0
  11. package/dist/exports/instrumentation-DBO6QH4d.d.ts.map +1 -0
  12. package/dist/exports/instrumentation.js +25 -0
  13. package/dist/exports/instrumentation.js.map +1 -0
  14. package/dist/exports/proxy-Cv7QFVci.d.ts +71 -0
  15. package/dist/exports/proxy-Cv7QFVci.d.ts.map +1 -0
  16. package/dist/exports/proxy.js +254 -0
  17. package/dist/exports/proxy.js.map +1 -0
  18. package/dist/exports/server-BHObJHJ6.d.ts +402 -0
  19. package/dist/exports/server-BHObJHJ6.d.ts.map +1 -0
  20. package/dist/exports/server.js +1974 -0
  21. package/dist/exports/server.js.map +1 -0
  22. package/dist/http-_2M-UFRx.js +61 -0
  23. package/dist/http-_2M-UFRx.js.map +1 -0
  24. package/dist/index-CdYcvudT.d.ts +4 -0
  25. package/dist/index-CtfAJ0y7.d.ts +499 -0
  26. package/dist/index-CtfAJ0y7.d.ts.map +1 -0
  27. package/dist/index.js +201 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/route-DzFwERQP.js +65 -0
  30. package/dist/route-DzFwERQP.js.map +1 -0
  31. package/dist/store-CJzqrHsl.js +73 -0
  32. package/dist/store-CJzqrHsl.js.map +1 -0
  33. package/dist/types-BatUTdbZ.d.ts +15 -0
  34. package/dist/types-BatUTdbZ.d.ts.map +1 -0
  35. package/dist/types-D29_snYY.d.ts +191 -0
  36. package/dist/types-D29_snYY.d.ts.map +1 -0
  37. package/dist/utils-Ca7xh5mm.js +78 -0
  38. package/dist/utils-Ca7xh5mm.js.map +1 -0
  39. package/dist/utils-YwteTbk7.js +23 -0
  40. package/dist/utils-YwteTbk7.js.map +1 -0
  41. package/package.json +156 -0
  42. package/src/client/client.tsx +18 -0
  43. package/src/client/extra.tsx +21 -0
  44. package/src/client/label.tsx +24 -0
  45. package/src/client/layout.tsx +22 -0
  46. package/src/client/local.collection.ts +33 -0
  47. package/src/client/metadata.tsx +217 -0
  48. package/src/client/page.tsx +22 -0
  49. package/src/client/useApi.test.tsx +203 -0
  50. package/src/client/useApi.ts +59 -0
  51. package/src/client/useCollectionApi.test.tsx +201 -0
  52. package/src/client/useCollectionApi.ts +66 -0
  53. package/src/client/useExtra.tsx +12 -0
  54. package/src/client/useHttp.ts +54 -0
  55. package/src/client/useLabel.test.tsx +76 -0
  56. package/src/client/useLabel.tsx +12 -0
  57. package/src/client/useLayout.tsx +13 -0
  58. package/src/client/useLink.tsx +13 -0
  59. package/src/client/useMetaData.tsx +12 -0
  60. package/src/client/usePage.tsx +12 -0
  61. package/src/client/useUser.tsx +12 -0
  62. package/src/client/user.tsx +43 -0
  63. package/src/exports/client.ts +20 -0
  64. package/src/exports/instrumentation.ts +1 -0
  65. package/src/exports/proxy.ts +5 -0
  66. package/src/exports/server.ts +19 -0
  67. package/src/global.d.ts +6 -0
  68. package/src/index.ts +17 -0
  69. package/src/instrumentation/client.ts +19 -0
  70. package/src/instrumentation/sentry/capture.requestError.ts +6 -0
  71. package/src/instrumentation/sentry/capture.routerTransitionStart.ts +4 -0
  72. package/src/instrumentation/sentry/config.client.ts +9 -0
  73. package/src/instrumentation/sentry/config.edge.ts +9 -0
  74. package/src/instrumentation/sentry/config.server.ts +9 -0
  75. package/src/instrumentation/server.ts +24 -0
  76. package/src/instrumentation/types.ts +12 -0
  77. package/src/lib/breadcrumb.ts +21 -0
  78. package/src/lib/const.ts +3 -0
  79. package/src/lib/http.test.ts +25 -0
  80. package/src/lib/http.ts +74 -0
  81. package/src/lib/label.ts +43 -0
  82. package/src/lib/layout.defaults.ts +167 -0
  83. package/src/lib/link.ts +19 -0
  84. package/src/lib/meta-data.ts +60 -0
  85. package/src/lib/open-graph.ts +165 -0
  86. package/src/lib/pagination.ts +78 -0
  87. package/src/lib/payload.config.ts +12 -0
  88. package/src/lib/route.ts +83 -0
  89. package/src/lib/store.ts +63 -0
  90. package/src/lib/structured-data.ts +24 -0
  91. package/src/lib/twitter-card.ts +49 -0
  92. package/src/lib/utils.ts +45 -0
  93. package/src/mock.ts +264 -0
  94. package/src/proxy/protected-route.test.ts +75 -0
  95. package/src/proxy/protected-route.ts +57 -0
  96. package/src/proxy/proxy.test.ts +48 -0
  97. package/src/proxy/proxy.ts +64 -0
  98. package/src/proxy/route-auto-detection.ts +126 -0
  99. package/src/proxy/route-proxy.ts +187 -0
  100. package/src/server/context.ts +147 -0
  101. package/src/server/handlers/auth.handler.ts +27 -0
  102. package/src/server/handlers/captions.handler.ts +32 -0
  103. package/src/server/handlers/collection.handler.test.ts +57 -0
  104. package/src/server/handlers/collection.handler.ts +212 -0
  105. package/src/server/handlers/error-response.ts +45 -0
  106. package/src/server/handlers/index.handler.ts +146 -0
  107. package/src/server/handlers/preview.handler.ts +63 -0
  108. package/src/server/handlers/registry.handler.test.ts +65 -0
  109. package/src/server/handlers/registry.handler.ts +259 -0
  110. package/src/server/handlers/route.handler.ts +161 -0
  111. package/src/server/handlers/sitemap.handler.ts +374 -0
  112. package/src/server/handlers/static-params.handler.ts +111 -0
  113. package/src/server/handlers/vars.handler.ts +167 -0
  114. package/src/server/layout.ts +120 -0
  115. package/src/server/lazy.ts +41 -0
  116. package/src/server/local-api.ts +63 -0
  117. package/src/server/metadata.ts +87 -0
  118. package/src/server/mixer-payload.ts +124 -0
  119. package/src/server/mixer-payload.type-test.ts +27 -0
  120. package/src/server/mixer.ts +439 -0
  121. package/src/server/mock/category.ts +34 -0
  122. package/src/server/mock/collection.ts +21 -0
  123. package/src/server/mock/page.ts +62 -0
  124. package/src/server/mock/route.ts +36 -0
  125. package/src/server/mock/store.ts +159 -0
  126. package/src/server/page.ts +86 -0
  127. package/src/server/session/session.ts +101 -0
  128. package/src/server/utils.ts +23 -0
  129. package/src/server/utmz.ts +11 -0
  130. package/src/types.ts +211 -0
package/src/mock.ts ADDED
@@ -0,0 +1,264 @@
1
+ import type { TypedLocale } from 'payload';
2
+
3
+ type Simplify<T> = { [Key in keyof T]: T[Key] } & {};
4
+
5
+ type RequiredKeys<T> = {
6
+ [Key in keyof T]-?: Pick<T, Key> extends Required<Pick<T, Key>> ? Key : never;
7
+ }[keyof T];
8
+
9
+ type PayloadSchema = {
10
+ collections: object;
11
+ globals?: object;
12
+ };
13
+
14
+ type MockLocale = Extract<TypedLocale, string>;
15
+
16
+ type SystemField =
17
+ | 'apiKeyIndex'
18
+ | 'createdAt'
19
+ | 'hash'
20
+ | 'lockUntil'
21
+ | 'loginAttempts'
22
+ | 'resetPasswordExpiration'
23
+ | 'resetPasswordToken'
24
+ | 'salt'
25
+ | 'sessions'
26
+ | 'updatedAt'
27
+ | '_status';
28
+
29
+ type LocalizedField =
30
+ | 'abstract'
31
+ | 'alt'
32
+ | 'description'
33
+ | 'extra'
34
+ | 'keywords'
35
+ | 'name'
36
+ | 'robots'
37
+ | 'slug'
38
+ | 'text'
39
+ | 'title';
40
+
41
+ type LexicalState = {
42
+ root: {
43
+ children: unknown[];
44
+ type: string;
45
+ };
46
+ };
47
+
48
+ type MockValue<T, Locale extends string, AdditionalLocalizedFields extends PropertyKey> =
49
+ T extends readonly (infer Item)[]
50
+ ? MockValue<Item, Locale, AdditionalLocalizedFields>[]
51
+ : T extends LexicalState
52
+ ? T | string
53
+ : T extends object
54
+ ? MockObject<T, Locale, AdditionalLocalizedFields>
55
+ : T;
56
+
57
+ type LocalizedMockValue<T, Locale extends string, AdditionalLocalizedFields extends PropertyKey> =
58
+ MockValue<T, Locale, AdditionalLocalizedFields>
59
+ | Partial<Record<Locale, MockValue<T, Locale, AdditionalLocalizedFields>>>;
60
+
61
+ type MockProperty<
62
+ Key extends PropertyKey,
63
+ Value,
64
+ Locale extends string,
65
+ AdditionalLocalizedFields extends PropertyKey,
66
+ > = Key extends LocalizedField | AdditionalLocalizedFields
67
+ ? LocalizedMockValue<Value, Locale, AdditionalLocalizedFields>
68
+ : Key extends 'media'
69
+ ? IMockMedia<Locale> | MockValue<Value, Locale, AdditionalLocalizedFields>
70
+ : MockValue<Value, Locale, AdditionalLocalizedFields>;
71
+
72
+ type MockObject<T extends object, Locale extends string, AdditionalLocalizedFields extends PropertyKey> = Simplify<
73
+ {
74
+ [Key in Exclude<keyof T, RequiredKeys<T> | SystemField>]?: MockProperty<
75
+ Key,
76
+ T[Key],
77
+ Locale,
78
+ AdditionalLocalizedFields
79
+ >;
80
+ } & {
81
+ [Key in Exclude<RequiredKeys<T>, SystemField>]?: MockProperty<
82
+ Key,
83
+ T[Key],
84
+ Locale,
85
+ AdditionalLocalizedFields
86
+ >;
87
+ }
88
+ >;
89
+
90
+ /** A locale map accepted by localized Payload fields during seeding. */
91
+ export type IMockLocalized<T = string, Locale extends string = MockLocale> = Partial<Record<Locale, T>>;
92
+
93
+ /** Compact upload input accepted by the seed plugin before it creates a Media document. */
94
+ export type IMockMedia<Locale extends string = MockLocale> = {
95
+ alt?: string | IMockLocalized<string, Locale>;
96
+ id?: string | number;
97
+ src: string;
98
+ type?: string;
99
+ };
100
+
101
+ /**
102
+ * Seed input derived from a Payload document type.
103
+ *
104
+ * Database-managed fields are omitted, document fields are optional to support
105
+ * `$inherit`, and conventional localized field names also accept locale maps.
106
+ */
107
+ export type IMockDocument<
108
+ Document extends object = Record<string, unknown>,
109
+ Locale extends string = MockLocale,
110
+ AdditionalLocalizedFields extends PropertyKey = never,
111
+ > = Simplify<
112
+ MockObject<Document, Locale, AdditionalLocalizedFields> & {
113
+ $inherit?: boolean | readonly (Extract<keyof Document, string>)[];
114
+ id?: string | number;
115
+ }
116
+ >;
117
+
118
+ /** Seed input for an arbitrary Payload collection. */
119
+ export type IMockCollection<
120
+ Document extends object = Record<string, unknown>,
121
+ Locale extends string = MockLocale,
122
+ AdditionalLocalizedFields extends PropertyKey = never,
123
+ > = IMockDocument<Document, Locale, AdditionalLocalizedFields>;
124
+
125
+ /** Seed input for a Payload global. */
126
+ export type IMockGlobal<
127
+ Global extends object = Record<string, unknown>,
128
+ Locale extends string = MockLocale,
129
+ AdditionalLocalizedFields extends PropertyKey = never,
130
+ > = IMockDocument<Global, Locale, AdditionalLocalizedFields>;
131
+
132
+ /**
133
+ * Seed input for a page collection. It has the same generic seed semantics as
134
+ * a collection document; page-specific fields remain inferred from the
135
+ * project's generated Payload type.
136
+ */
137
+ export type IMockPage<
138
+ Document extends object = Record<string, unknown>,
139
+ Locale extends string = MockLocale,
140
+ AdditionalLocalizedFields extends PropertyKey = never,
141
+ > = IMockDocument<Document, Locale, AdditionalLocalizedFields>;
142
+
143
+ type DefaultMenuItem = {
144
+ abstract?: string;
145
+ category?: string;
146
+ customClass?: string;
147
+ customTitle?: string;
148
+ extra?: string;
149
+ href?: string;
150
+ items?: DefaultMenuItem[];
151
+ markets?: string[];
152
+ maxDepth?: number;
153
+ page?: { relationTo: string; value: string | number };
154
+ strategy?: string;
155
+ target?: string;
156
+ title?: string;
157
+ type?: 'page' | 'category' | 'link' | 'group';
158
+ };
159
+
160
+ type DefaultMenuDocument = {
161
+ id?: string | number;
162
+ items?: DefaultMenuItem[] | null;
163
+ markets?: string[];
164
+ };
165
+
166
+ type MenuDocument = {
167
+ items?: readonly object[] | null;
168
+ };
169
+
170
+ type MenuItem<Document extends MenuDocument> = NonNullable<NonNullable<Document['items']>[number]>;
171
+
172
+ type MockMenuField<
173
+ Item extends object,
174
+ Key extends keyof Item,
175
+ Locale extends string,
176
+ AdditionalLocalizedFields extends PropertyKey,
177
+ > = MockProperty<Key, Item[Key], Locale, AdditionalLocalizedFields>;
178
+
179
+ type MockMenuItemBase<
180
+ Item extends object,
181
+ Locale extends string,
182
+ AdditionalLocalizedFields extends PropertyKey,
183
+ > = Omit<MockObject<Item, Locale, AdditionalLocalizedFields>, 'blockName' | 'blockType' | 'category' | 'href' | 'items' | 'page' | 'title' | 'type'>;
184
+
185
+ /** A recursive author-facing menu item; seed setup adds Payload block metadata. */
186
+ export type IMockMenuItem<
187
+ Document extends MenuDocument = DefaultMenuDocument,
188
+ Locale extends string = MockLocale,
189
+ AdditionalLocalizedFields extends PropertyKey = never,
190
+ Item extends object = MenuItem<Document>,
191
+ > =
192
+ | (MockMenuItemBase<Item, Locale, AdditionalLocalizedFields> & {
193
+ category: NonNullable<MockMenuField<Item, Extract<'category', keyof Item>, Locale, AdditionalLocalizedFields>>;
194
+ type: 'category';
195
+ })
196
+ | (MockMenuItemBase<Item, Locale, AdditionalLocalizedFields> & {
197
+ page: NonNullable<MockMenuField<Item, Extract<'page', keyof Item>, Locale, AdditionalLocalizedFields>>;
198
+ type: 'page';
199
+ })
200
+ | (MockMenuItemBase<Item, Locale, AdditionalLocalizedFields> & {
201
+ href: NonNullable<MockMenuField<Item, Extract<'href', keyof Item>, Locale, AdditionalLocalizedFields>>;
202
+ title: NonNullable<MockMenuField<Item, Extract<'title', keyof Item>, Locale, AdditionalLocalizedFields>>;
203
+ type: 'link';
204
+ })
205
+ | (MockMenuItemBase<Item, Locale, AdditionalLocalizedFields> & {
206
+ items: IMockMenuItem<Document, Locale, AdditionalLocalizedFields>[];
207
+ title?: MockMenuField<Item, Extract<'title', keyof Item>, Locale, AdditionalLocalizedFields>;
208
+ type: 'group';
209
+ });
210
+
211
+ /**
212
+ * Seed input for a recursive Payload menu collection.
213
+ *
214
+ * Its items intentionally omit generated `Level N` block metadata because
215
+ * seed registration assigns it from the nesting level before persistence.
216
+ */
217
+ export type IMockMenu<
218
+ Document extends MenuDocument = DefaultMenuDocument,
219
+ Locale extends string = MockLocale,
220
+ AdditionalLocalizedFields extends PropertyKey = never,
221
+ > = Simplify<
222
+ Omit<IMockDocument<Document, Locale, AdditionalLocalizedFields>, 'items'> & {
223
+ items: IMockMenuItem<Document, Locale, AdditionalLocalizedFields>[];
224
+ }
225
+ >;
226
+
227
+ /** Legacy locale seed record for projects that manage locales outside generated Payload collections. */
228
+ export type IMockLocale<Locale extends string = MockLocale> = {
229
+ id: Locale;
230
+ isDefault?: boolean;
231
+ isHidden?: boolean;
232
+ title: string | IMockLocalized<string, Locale>;
233
+ };
234
+
235
+ type CollectionSlug<Schema extends PayloadSchema> = Extract<keyof Schema['collections'], string>;
236
+ type CollectionDocument<Schema extends PayloadSchema, Slug extends CollectionSlug<Schema>> = Schema['collections'][Slug] extends object
237
+ ? Schema['collections'][Slug]
238
+ : never;
239
+
240
+ type MockCollections<
241
+ Schema extends PayloadSchema,
242
+ PageSlug extends CollectionSlug<Schema>,
243
+ Locale extends string,
244
+ AdditionalCollections extends Record<string, object>,
245
+ > = {
246
+ [Slug in Exclude<CollectionSlug<Schema>, PageSlug>]?: IMockCollection<CollectionDocument<Schema, Slug>, Locale>[];
247
+ } & {
248
+ [Slug in keyof AdditionalCollections]?: IMockCollection<AdditionalCollections[Slug], Locale>[];
249
+ };
250
+
251
+ type MockPages<Schema extends PayloadSchema, PageSlug extends CollectionSlug<Schema>, Locale extends string> = {
252
+ [Slug in PageSlug]?: IMockPage<CollectionDocument<Schema, Slug>, Locale>[];
253
+ };
254
+
255
+ /** A typed collection/page store for the memory-store and Payload seed inputs. */
256
+ export type IMockStore<
257
+ Schema extends PayloadSchema = { collections: Record<string, Record<string, unknown>> },
258
+ PageSlug extends CollectionSlug<Schema> = never,
259
+ AdditionalCollections extends Record<string, object> = Record<never, never>,
260
+ Locale extends string = MockLocale,
261
+ > = {
262
+ collections: MockCollections<Schema, PageSlug, Locale, AdditionalCollections>;
263
+ pages: MockPages<Schema, PageSlug, Locale>;
264
+ };
@@ -0,0 +1,75 @@
1
+ // @vitest-environment node
2
+
3
+ import { IRoute } from '@websolutespa/bom-core';
4
+ import { NextRequest } from 'next/server';
5
+ import { readFileSync } from 'node:fs';
6
+ import path from 'node:path';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+ import { localApiGet } from '../lib/http';
9
+ import { protectedRouteProxy } from './protected-route';
10
+
11
+ vi.mock('../lib/http', () => ({
12
+ localApiGet: vi.fn(),
13
+ }));
14
+
15
+ describe('protectedRouteProxy Edge boundary', () => {
16
+ test('does not import or initialize Payload in middleware', () => {
17
+ for (const fileName of ['protected-route.ts', 'proxy.ts', 'route-proxy.ts']) {
18
+ const source = readFileSync(path.join(import.meta.dirname, fileName), 'utf8');
19
+
20
+ expect(source).not.toContain('from \'../server/mixer\'');
21
+ expect(source).not.toContain('getPayload(');
22
+ expect(source).not.toContain('from \'next/headers\'');
23
+ expect(source).not.toMatch(/^import\s+\{[^}]+\}\s+from\s+['"]payload['"]/m);
24
+ }
25
+ });
26
+ });
27
+
28
+ describe('protectedRouteProxy', () => {
29
+ test('redirects unauthenticated protected requests to a safe public fallback', async () => {
30
+ const request = new NextRequest('https://app.example.test/members');
31
+ const route: IRoute = {
32
+ category: 'members',
33
+ id: '/members',
34
+ isProtected: true,
35
+ locale: 'en',
36
+ market: 'ww',
37
+ page: 'members',
38
+ publicRedirect: '/sign-in',
39
+ resolvedCategory: 'members',
40
+ schema: 'page',
41
+ title: 'Members',
42
+ };
43
+
44
+ const response = await protectedRouteProxy({ request, next: {} as never, route });
45
+
46
+ expect(response?.status).toBe(307);
47
+ expect(response?.headers.get('location')).toBe('https://app.example.test/sign-in');
48
+ });
49
+ test('falls back to the login resolver for an unsafe public fallback', async () => {
50
+ const request = new NextRequest('https://app.example.test/members');
51
+ const route: IRoute = {
52
+ category: 'members',
53
+ id: '/members',
54
+ isProtected: true,
55
+ locale: 'en',
56
+ market: 'ww',
57
+ page: 'members',
58
+ publicRedirect: '//untrusted.example.test/sign-in',
59
+ resolvedCategory: 'members',
60
+ schema: 'page',
61
+ title: 'Members',
62
+ };
63
+
64
+ vi.mocked(localApiGet).mockResolvedValueOnce(undefined);
65
+ const response = await protectedRouteProxy({ request, next: {} as never, route });
66
+
67
+ expect(response).toBeUndefined();
68
+ expect(localApiGet).toHaveBeenCalledWith(
69
+ '/-route?schema=login&market=ww&locale=en',
70
+ {},
71
+ undefined,
72
+ 'https://app.example.test'
73
+ );
74
+ });
75
+ });
@@ -0,0 +1,57 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { localApiGet } from '../lib/http';
3
+ import { getNextUrl } from '../server/utils';
4
+ import { getRequestOrigin } from './proxy';
5
+ import type { RouteMiddleware, RouteMiddlewareOptions, RouteResolverResponse } from './route-proxy';
6
+
7
+ function getPublicRedirectUrl(request: RouteMiddlewareOptions['request'], href: string): URL | undefined {
8
+ if (!href.startsWith('/') || href.startsWith('//')) {
9
+ return undefined;
10
+ }
11
+ const url = new URL(href, getRequestOrigin(request));
12
+ return url.pathname === request.nextUrl.pathname ? undefined : url;
13
+ }
14
+
15
+ export const protectedRouteProxy: RouteMiddleware = async ({ request, route }: RouteMiddlewareOptions) => {
16
+ // console.log('protectedRouteProxy', 'token', token);
17
+ if (route.isProtected) {
18
+ const token = request.cookies.get('payload-token');
19
+ const isAuthenticated = token; // await hasValidSession();
20
+ console.log('protectedRouteProxy.isAuthenticated', isAuthenticated);
21
+ if (!isAuthenticated) {
22
+ if (route.publicRedirect) {
23
+ const redirectUrl = getPublicRedirectUrl(request, route.publicRedirect);
24
+ if (redirectUrl) {
25
+ return NextResponse.redirect(redirectUrl);
26
+ }
27
+ }
28
+ const url = request.nextUrl;
29
+ const { pathname } = url;
30
+ const reqHeaders = new Headers(request.headers);
31
+ reqHeaders.set('x-pathname', pathname);
32
+ const resolution = await localApiGet<RouteResolverResponse>(
33
+ `/-route?schema=login&market=${encodeURIComponent(route.market)}&locale=${encodeURIComponent(route.locale)}`,
34
+ {},
35
+ undefined,
36
+ getRequestOrigin(request)
37
+ );
38
+ const nextRoute = resolution?.route;
39
+ if (nextRoute) {
40
+ const nextUrl = getNextUrl(request, nextRoute);
41
+ reqHeaders.set('x-market', nextRoute.market);
42
+ reqHeaders.set('x-locale', nextRoute.locale);
43
+ reqHeaders.set('x-schema', nextRoute.schema);
44
+ reqHeaders.set('x-id', String(nextRoute.page));
45
+ reqHeaders.set('x-forwarded-pathname', nextUrl.pathname);
46
+ console.log('protectedRouteProxy.Protected', pathname, '>', nextUrl.pathname);
47
+ const response = NextResponse
48
+ .rewrite(nextUrl, {
49
+ request: {
50
+ headers: reqHeaders,
51
+ },
52
+ });
53
+ return response;
54
+ }
55
+ }
56
+ }
57
+ };
@@ -0,0 +1,48 @@
1
+ // @vitest-environment node
2
+
3
+ import { NextRequest } from 'next/server';
4
+ import { describe, expect, test } from 'vitest';
5
+ import { getRequestOrigin } from './proxy';
6
+
7
+ describe('getRequestOrigin', () => {
8
+ test('uses Vercel forwarded host and protocol', () => {
9
+ const request = new NextRequest('https://bom-next-79lpg3gby-luca-zampettis-projects.vercel.app/', {
10
+ headers: {
11
+ 'x-forwarded-host': 'bom-next-tau.vercel.app',
12
+ 'x-forwarded-port': '443',
13
+ 'x-forwarded-proto': 'https',
14
+ },
15
+ });
16
+
17
+ expect(getRequestOrigin(request)).toBe('https://bom-next-tau.vercel.app');
18
+ });
19
+
20
+ test('uses a non-default port supplied by a reverse proxy', () => {
21
+ const request = new NextRequest('http://internal-app:3000/', {
22
+ headers: {
23
+ 'x-forwarded-host': 'app.example.com',
24
+ 'x-forwarded-port': '8443',
25
+ 'x-forwarded-proto': 'https',
26
+ },
27
+ });
28
+
29
+ expect(getRequestOrigin(request)).toBe('https://app.example.com:8443');
30
+ });
31
+
32
+ test('uses the direct request origin without forwarded headers', () => {
33
+ const request = new NextRequest('http://localhost:3000/');
34
+
35
+ expect(getRequestOrigin(request)).toBe('http://localhost:3000');
36
+ });
37
+
38
+ test('falls back to the direct request origin when forwarded protocol is invalid', () => {
39
+ const request = new NextRequest('https://app.example.com/', {
40
+ headers: {
41
+ 'x-forwarded-host': 'untrusted.example.com',
42
+ 'x-forwarded-proto': 'ftp',
43
+ },
44
+ });
45
+
46
+ expect(getRequestOrigin(request)).toBe('https://app.example.com');
47
+ });
48
+ });
@@ -0,0 +1,64 @@
1
+ import { NextMiddlewareResult } from 'next/dist/server/web/types';
2
+ import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
3
+
4
+ // export type Middleware = (request: NextRequest, next: NextFetchEvent) => Promise<NextMiddlewareResult>;
5
+
6
+ export type MiddlewareOptions = {
7
+ request: NextRequest,
8
+ next: NextFetchEvent,
9
+ disabled?: boolean,
10
+ };
11
+ export type Middleware = (props: MiddlewareOptions) => NextMiddlewareResult | Promise<NextMiddlewareResult>;
12
+
13
+ /**
14
+ * Returns the externally visible origin for an internal middleware request.
15
+ *
16
+ * Deployments commonly terminate TLS or change ports before forwarding to
17
+ * Next.js. Honoring valid forwarded headers keeps resolver requests on the
18
+ * public host and prevents an avoidable proxy redirect; direct-origin fallback
19
+ * keeps local development and untrusted/invalid header values deterministic.
20
+ */
21
+ export function getRequestOrigin(request: NextRequest): string {
22
+ const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
23
+ const forwardedProtocol = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim();
24
+ const forwardedPort = request.headers.get('x-forwarded-port')?.split(',')[0]?.trim();
25
+
26
+ if (!forwardedHost || !['http', 'https'].includes(forwardedProtocol || '')) {
27
+ return request.nextUrl.origin;
28
+ }
29
+
30
+ const protocol = forwardedProtocol!;
31
+ const defaultPort = protocol === 'https' ? '443' : '80';
32
+ const includesPort = forwardedHost.startsWith('[') ? forwardedHost.includes(']:') : forwardedHost.includes(':');
33
+ const authority = forwardedPort && forwardedPort !== defaultPort && !includesPort ? `${forwardedHost}:${forwardedPort}` : forwardedHost;
34
+
35
+ return `${protocol}://${authority}`;
36
+ }
37
+
38
+ /**
39
+ * Composes middleware into an ordered, short-circuiting proxy pipeline.
40
+ *
41
+ * A disabled proxy returns immediately, avoiding all downstream request work.
42
+ * Otherwise each middleware can claim the response; stopping at its first
43
+ * result prevents duplicate rewrites, redirects, and expensive policy checks.
44
+ */
45
+ export function createProxy(...proxies: Middleware[]): Middleware {
46
+ return async function ({ request, next, disabled }: MiddlewareOptions) {
47
+ if (disabled) {
48
+ return NextResponse.next();
49
+ }
50
+ let proxyResponse, i = 0;
51
+ while (i < proxies.length && !proxyResponse) {
52
+ const proxy = proxies[i] as Middleware;
53
+ // console.log('createProxy.proxies', proxies.length, typeof proxy === 'function');
54
+ if (typeof proxy === 'function') {
55
+ proxyResponse = await proxy({ request, next });
56
+ if (proxyResponse) {
57
+ return proxyResponse;
58
+ }
59
+ }
60
+ i++;
61
+ }
62
+ return NextResponse.next();
63
+ };
64
+ }
@@ -0,0 +1,126 @@
1
+ 'use server';
2
+
3
+ import { asEquatable, defaultLocale, defaultMarket, IMarket, IRoute } from '@websolutespa/bom-core';
4
+ import { NextRequest, NextResponse } from 'next/server';
5
+ import { localApiGet } from '../lib/http';
6
+ import { getRequestOrigin, Middleware, MiddlewareOptions } from './proxy';
7
+
8
+ export async function detectLocale(request: NextRequest, defaultLocale?: string) {
9
+ const acceptLanguage = request.headers.get('accept-language')?.split(',')[0];
10
+ const detectedLocale = request.cookies.get('NEXT_LOCALE')?.value || acceptLanguage || defaultLocale || process.env.DEFAULT_LOCALE;
11
+ return detectedLocale as string;
12
+ }
13
+
14
+ export async function detectCountry(request: NextRequest, defaultMarket?: string) {
15
+ let ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip');
16
+ if (ip) {
17
+ ip = ip.split(',')[0];
18
+ }
19
+ const isValidIp = ip?.match(/^(\d+?)\.(\d+?)\.(\d+?)\.(\d+?)$/);
20
+ let detectedCountry = '';
21
+ try {
22
+ const token = process.env.IPINFO_TOKEN;
23
+ if (token) {
24
+ const url = isValidIp ?
25
+ `https://api.ipinfo.io/lite/${ip}/country_code?token=${token}` :
26
+ `https://api.ipinfo.io/lite/me/country_code?token=${token}`;
27
+ const response = await fetch(url);
28
+ if (response.ok) {
29
+ const data = await response.text();
30
+ detectedCountry = data.toLowerCase();
31
+ }
32
+ } else {
33
+ const url = `https://geoip.websolute.it/Ip2Location/get_info.aspx?ipaddress=${isValidIp ? ip : ''}`;
34
+ const response = await fetch(url);
35
+ if (response.ok) {
36
+ const xml = await response.text();
37
+ detectedCountry = (
38
+ ((xml.split('<CountryCode><![CDATA[')[1] || '').split(']]></CountryCode>')[0] || '')
39
+ ).replace('-', '').toLowerCase();
40
+ // console.log('detectedCountry', detectedCountry);
41
+ }
42
+ }
43
+ // console.log('routeInterceptor.detectCountry', detectedCountry);
44
+ return detectedCountry || defaultMarket || process.env.DEFAULT_MARKET;
45
+ } catch (error) {
46
+ console.log('routeInterceptor.detectCountry.error', error);
47
+ }
48
+ return (defaultMarket || process.env.DEFAULT_MARKET) as string;
49
+ }
50
+
51
+ export const routeAutoDetection: Middleware = async ({ request }: MiddlewareOptions) => {
52
+ let redirectUrl: string | undefined = undefined;
53
+ const url = request.nextUrl;
54
+ const origin = getRequestOrigin(request);
55
+ if (url.pathname === '/') {
56
+ const cookie = request.cookies.get('detectedRoute');
57
+ if (cookie?.value) {
58
+ redirectUrl = cookie.value;
59
+ }
60
+ // console.log('routeInterceptor.routeAutoDetection', url.pathname);
61
+ const rootRoutes: IRoute[] = await localApiGet('/-route?where[isRoot][equals]=true', {}, undefined, origin);
62
+ // console.log('routeInterceptor.routeAutoDetection.rootRoutes', rootRoutes);
63
+ const defaultRoute = rootRoutes.find(x => x.id === '/');
64
+ // console.log('routeInterceptor.routeAutoDetection.defaultRoute', defaultRoute);
65
+ if (defaultRoute) {
66
+ return;
67
+ }
68
+ const markets: IMarket[] = await localApiGet('/market?pagination=false', {}, undefined, origin);
69
+ // console.log('routeInterceptor.routeAutoDetection.markets', markets);
70
+ const market = markets.find(x => x.isDefault) || markets.find(x => x.id === defaultMarket) || markets[0];
71
+ // console.log('routeInterceptor.routeAutoDetection.market', market);
72
+ const detectedCountry = await detectCountry(request, market.id);
73
+ // console.log('routeInterceptor.routeAutoDetection.detectedCountry', detectedCountry);
74
+ const detectedMarket = detectedCountry ? (
75
+ markets.filter(x => Array.isArray(x.countries)).find(x => {
76
+ const countries: string[] = (x.countries || []).map(x => typeof x === 'string' ? x : x.id as string);
77
+ return countries.includes(detectedCountry);
78
+ }) ||
79
+ markets.find(x => !Array.isArray(x.countries))
80
+ ) : undefined;
81
+ // console.log('routeInterceptor.routeAutoDetection.detectedMarket', detectedMarket);
82
+ let detectedMarketRoutes = rootRoutes.filter(x => x.market === detectedMarket?.id);
83
+ if (detectedMarketRoutes.length === 0) {
84
+ detectedMarketRoutes = rootRoutes.filter(x => x.market === market.id);
85
+ }
86
+ // console.log('routeInterceptor.routeAutoDetection.detectedMarketRoutes', detectedMarketRoutes);
87
+ const defaultMarketLocale = market.defaultLanguage ? (
88
+ typeof market.defaultLanguage === 'string' ? market.defaultLanguage : market.defaultLanguage.id
89
+ ) as string : defaultLocale;
90
+ // console.log('routeInterceptor.routeAutoDetection.defaultMarketLocale', defaultMarketLocale);
91
+ const detectedLocale = await detectLocale(request, defaultMarketLocale);
92
+ // console.log('routeInterceptor.routeAutoDetection.detectedLocale', detectedLocale);
93
+ const detectedLocaleRoutes = detectedMarketRoutes.filter(x => x.locale === detectedLocale);
94
+ const detectedRoute =
95
+ detectedLocaleRoutes.length > 0 ?
96
+ detectedLocaleRoutes[0]! :
97
+ detectedMarketRoutes.find(x => {
98
+ const market = markets.find(m => m.id === x.market)!;
99
+ const marketLocale = (market.defaultLanguage ? asEquatable(market.defaultLanguage as string) : defaultMarketLocale);
100
+ return x.locale === marketLocale;
101
+ })!;
102
+ // console.log('routeInterceptor.routeAutoDetection.detectedRoute', detectedRoute);
103
+ if (detectedRoute && detectedRoute.id !== url.pathname) {
104
+ console.log('routeInterceptor.routeAutoDetection', detectedLocale, detectedCountry, detectedRoute.id);
105
+ redirectUrl = detectedRoute.id;
106
+ }
107
+ // console.log('routeInterceptor.routeAutoDetection', locale, detectedCountry, rootRoutes);
108
+ }
109
+ if (redirectUrl) {
110
+ // console.log('routeInterceptor', 'redirectUrl', redirectUrl);
111
+ const resolvedUrl = new URL(redirectUrl, origin).href;
112
+ // console.log('routeInterceptor', 'resolvedUrl', resolvedUrl);
113
+ const search = request.nextUrl.search;
114
+ const response = NextResponse.redirect(resolvedUrl + search, { status: 307 });
115
+ response.cookies.set({
116
+ expires: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000),
117
+ httpOnly: true,
118
+ name: 'detectedRoute',
119
+ path: '/',
120
+ sameSite: 'lax',
121
+ secure: true,
122
+ value: redirectUrl,
123
+ });
124
+ return response;
125
+ }
126
+ };