orionplace-react 0.3.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-react",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -89,7 +89,7 @@ and Next.js configuration are preserved.
89
89
  | \`../${base}app/_storefront/\` | Route controllers that connect components, navigation and API actions. |
90
90
  | \`../${base}app/api/[...orionplace]/route.ts\` | The route handler serving \`/api/...\` from your project. |
91
91
  | \`../${base}app/ps3/blog/[...orionplace]/route.ts\` | The route handler serving guarded blog images and videos under \`/ps3/blog/...\`. |
92
- | \`../${base}proxy.ts\` | Marks personalized pages private and varies them by storefront domain. |
92
+ | \`../${base}proxy.ts\` | Marks personalized pages private and answers HTTP 503 when Application Load fails. |
93
93
  | \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
94
94
  | \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
95
95
 
@@ -311,9 +311,12 @@ Failures also survive hydration and navigation. They never open a login modal or
311
311
  trigger an automatic retry on every route. The supplied shell shows
312
312
  \`StorefrontErrorPage\` and keeps the header, route content and footer unmounted
313
313
  until initialization succeeds. Retry reloads the page; an unknown address keeps
314
- the 404 page. App Router components cannot set the response status, so this
315
- error page is sent with HTTP 200. Feature components should check
316
- \`selectIsReady\` before using required data.
314
+ the 404 page. App Router pages cannot change their status once they stream, so
315
+ \`${base}proxy.ts\` starts Application Load for a full page load and hands it
316
+ to the render (still one request): when it fails, the error page is sent with
317
+ HTTP 503. A project that keeps its own \`proxy.ts\` sends it with HTTP 200 unless
318
+ it does the same. Feature components should check \`selectIsReady\` before using
319
+ required data.
317
320
 
318
321
  Page responses use \`private, no-store\` through \`${base}proxy.ts\`. Do not put
319
322
  personalized HTML in shared caches or persist the snapshot in localStorage. The
@@ -31,8 +31,25 @@ const FORWARDED = [
31
31
  'x-tz',
32
32
  ];
33
33
 
34
- async function buildRequest(signal: AbortSignal): Promise<Request> {
35
- const incoming = await headers();
34
+ /** Header the proxy sets on a page request whose Application Load it started. */
35
+ export const APP_LOAD_HANDOFF_HEADER = 'x-orionplace-appload';
36
+ /** How long a started Application Load waits for the layout to take it. */
37
+ const HANDOFF_TTL = 60_000;
38
+ const HANDOFF_KEY = Symbol.for('orionplace.appLoadHandoff');
39
+
40
+ /**
41
+ * Started loads by id. Kept on `globalThis`: the proxy and the page render run
42
+ * in the same Node process but in separate bundles.
43
+ */
44
+ function handoffs(): Map<string, Promise<ServerAppLoad>> {
45
+ const scope = globalThis as typeof globalThis & {
46
+ [HANDOFF_KEY]?: Map<string, Promise<ServerAppLoad>>;
47
+ };
48
+ scope[HANDOFF_KEY] ??= new Map();
49
+ return scope[HANDOFF_KEY];
50
+ }
51
+
52
+ function buildRequest(incoming: Headers, signal: AbortSignal): Request {
36
53
  const host =
37
54
  incoming.get('x-forwarded-host') || incoming.get('host') || 'localhost';
38
55
  const protocol =
@@ -74,12 +91,14 @@ async function buildRequest(signal: AbortSignal): Promise<Request> {
74
91
  */
75
92
  export async function requestAppLoad(
76
93
  signal?: AbortSignal,
94
+ incoming?: Headers,
77
95
  ): Promise<ServerAppLoad> {
78
96
  const timeout = AbortSignal.timeout(35_000);
79
97
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
80
98
  let response: Response;
81
99
  try {
82
- response = await forwardMarketplace(await buildRequest(combined), {
100
+ const request = buildRequest(incoming ?? new Headers(await headers()), combined);
101
+ response = await forwardMarketplace(request, {
83
102
  mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '',
84
103
  });
85
104
  } catch {
@@ -107,11 +126,37 @@ export async function requestAppLoad(
107
126
  };
108
127
  }
109
128
 
129
+ /**
130
+ * Starts Application Load for a page request in `proxy.ts`, before the page
131
+ * renders, so a failure can answer HTTP 503 (a page cannot change its status
132
+ * once it streams). Returns the id to pass in `APP_LOAD_HANDOFF_HEADER`; the
133
+ * render takes the same result, so the page still costs one request.
134
+ */
135
+ export function startAppLoad(incoming: Headers): {
136
+ id: string;
137
+ load: Promise<ServerAppLoad>;
138
+ } {
139
+ const id = crypto.randomUUID();
140
+ const load = requestAppLoad(undefined, incoming);
141
+ const store = handoffs();
142
+ store.set(id, load);
143
+ setTimeout(() => store.delete(id), HANDOFF_TTL).unref?.();
144
+ return { id, load };
145
+ }
146
+
110
147
  /**
111
148
  * One request per server render, shared by the layout and any server component
112
149
  * that awaits it. Reading it in a layout initializes the marketplace before the
113
- * page renders; it never opens an authentication prompt.
150
+ * page renders; it never opens an authentication prompt. A load the proxy
151
+ * started for this request is taken once; otherwise the render requests it.
114
152
  */
115
- export const getOrionplaceAppLoad = cache((): Promise<ServerAppLoad> =>
116
- requestAppLoad(),
117
- );
153
+ export const getOrionplaceAppLoad = cache(async (): Promise<ServerAppLoad> => {
154
+ const incoming = await headers();
155
+ const id = incoming.get(APP_LOAD_HANDOFF_HEADER);
156
+ const started = id ? handoffs().get(id) : undefined;
157
+ if (id && started) {
158
+ handoffs().delete(id);
159
+ return started;
160
+ }
161
+ return requestAppLoad(undefined, new Headers(incoming));
162
+ });
@@ -1,17 +1,51 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
+ import {
3
+ APP_LOAD_HANDOFF_HEADER,
4
+ startAppLoad,
5
+ } from '@/lib/orionplace/appLoadServer';
6
+
7
+ /**
8
+ * A full page load: not an in-app navigation (`RSC`), a prefetch or a public
9
+ * file such as an image.
10
+ */
11
+ function pageLoad(request: NextRequest) {
12
+ const accept = request.headers.get('accept') ?? '';
13
+ return (
14
+ request.method === 'GET' &&
15
+ !request.headers.has('rsc') &&
16
+ !request.headers.has('next-router-prefetch') &&
17
+ !/\.[a-z0-9]+$/i.test(request.nextUrl.pathname) &&
18
+ (!accept || accept.includes('text/html') || accept.includes('*/*'))
19
+ );
20
+ }
2
21
 
3
22
  /**
4
23
  * Personalized pages are never shared between visitors or storefront domains.
5
24
  * Next.js manages its own `Vary` header for pages; the API gateway sets the
6
25
  * storefront-domain `Vary` on its own responses.
26
+ *
27
+ * A full page load starts Application Load here and hands it to the render:
28
+ * when it fails, the error page answers HTTP 503 like the Nuxt and SvelteKit
29
+ * starters (an unknown address keeps its 404).
7
30
  */
8
- export function proxy(request: NextRequest) {
31
+ export async function proxy(request: NextRequest) {
9
32
  const forwarded = new Headers(request.headers);
33
+ // Only this proxy names a started load; a visitor cannot pick one.
34
+ forwarded.delete(APP_LOAD_HANDOFF_HEADER);
10
35
  forwarded.set(
11
36
  'x-orionplace-page-url',
12
37
  request.nextUrl.pathname + request.nextUrl.search,
13
38
  );
14
- const response = NextResponse.next({ request: { headers: forwarded } });
39
+ let unavailable = false;
40
+ if (pageLoad(request)) {
41
+ const { id, load } = startAppLoad(forwarded);
42
+ forwarded.set(APP_LOAD_HANDOFF_HEADER, id);
43
+ unavailable = Boolean((await load).failure);
44
+ }
45
+ const response = NextResponse.next({
46
+ request: { headers: forwarded },
47
+ ...(unavailable ? { status: 503 } : {}),
48
+ });
15
49
  response.headers.set('Cache-Control', 'private, no-store');
16
50
  return response;
17
51
  }