@arcote.tech/platform 0.7.27 → 0.7.29

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.7.27",
4
+ "version": "0.7.29",
5
5
  "private": false,
6
6
  "author": "Przemysław Krasiński [arcote.tech]",
7
7
  "description": "Arc Platform — module system, router, layout, theme, i18n, platform app shell",
@@ -14,12 +14,12 @@
14
14
  "type-check": "tsc --noEmit"
15
15
  },
16
16
  "dependencies": {
17
- "@arcote.tech/arc-ds": "^0.7.27",
18
- "@arcote.tech/arc-react": "^0.7.27"
17
+ "@arcote.tech/arc-ds": "^0.7.29",
18
+ "@arcote.tech/arc-react": "^0.7.29"
19
19
  },
20
20
  "peerDependencies": {
21
- "@arcote.tech/arc": "^0.7.27",
22
- "@arcote.tech/arc-otel": "^0.7.27",
21
+ "@arcote.tech/arc": "^0.7.29",
22
+ "@arcote.tech/arc-otel": "^0.7.29",
23
23
  "@lingui/core": "^5.0.0",
24
24
  "@lingui/react": "^5.0.0",
25
25
  "framer-motion": "^12.0.0",
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  getSlotFragments,
24
24
  getVariantOverrides,
25
25
  getWrapperFragments,
26
+ isPublicPagePath,
26
27
  registerModule,
27
28
  setContext,
28
29
  setDefaultLayout,
@@ -110,27 +110,69 @@ function buildTokensHeader(tokens: Record<string, string>): string {
110
110
  .join(",");
111
111
  }
112
112
 
113
- function buildHeaders(tokens?: Record<string, string>): HeadersInit {
114
- const resolved = tokens ?? getAllPersistedTokens();
113
+ function buildHeaders(tokens: Record<string, string>): HeadersInit {
115
114
  const headers: HeadersInit = {};
116
- const tokensHeader = buildTokensHeader(resolved);
115
+ const tokensHeader = buildTokensHeader(tokens);
117
116
  if (tokensHeader) headers["X-Arc-Tokens"] = tokensHeader;
118
117
  // Also send first token as Authorization for backwards compatibility
119
- const firstToken = Object.values(resolved)[0];
118
+ const firstToken = Object.values(tokens)[0];
120
119
  if (firstToken) headers["Authorization"] = `Bearer ${firstToken}`;
121
120
  return headers;
122
121
  }
123
122
 
124
- /** Fetch manifest from server */
123
+ /** Remove the named token scopes from localStorage (invalid/rejected tokens). */
124
+ function clearPersistedTokens(scopes: string[]): void {
125
+ if (typeof localStorage === "undefined") return;
126
+ for (const scope of scopes) localStorage.removeItem(`arc:token:${scope}`);
127
+ }
128
+
129
+ /** Single manifest request with an explicit token map. `bust` forces a
130
+ * cache-busted request (query param + `cache: "reload"`). */
131
+ function fetchManifestOnce(
132
+ baseUrl: string,
133
+ tokens: Record<string, string>,
134
+ bust?: string,
135
+ ): Promise<Response> {
136
+ const url = bust ? `${baseUrl}/api/modules?t=${bust}` : `${baseUrl}/api/modules`;
137
+ return fetch(url, {
138
+ headers: buildHeaders(tokens),
139
+ ...(bust ? { cache: "reload" as RequestCache } : {}),
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Fetch the module manifest. Self-heals a rejected token: if the request
145
+ * carried token(s) and the server answers 401/403 (e.g. the token was minted
146
+ * with a rotated secret, or came from another environment), the offending
147
+ * tokens are dropped from localStorage and the manifest is refetched
148
+ * anonymously. Without this a single stale token bricks the whole boot with
149
+ * "Failed to load application" until the user manually clears storage.
150
+ *
151
+ * `bust` forces a cache-busted request — used by the group-import recovery
152
+ * path when a stale cached manifest pointed at chunks that no longer exist.
153
+ */
125
154
  async function fetchManifest(
126
155
  baseUrl: string,
127
156
  tokens?: Record<string, string>,
157
+ bust?: string,
128
158
  ): Promise<BuildManifest> {
129
- const headers = buildHeaders(tokens);
130
- const res = await fetch(`${baseUrl}/api/modules`, { headers });
131
- if (!res.ok)
132
- throw new Error(`Failed to fetch module manifest: ${res.status}`);
133
- return res.json();
159
+ const resolved = tokens ?? getAllPersistedTokens();
160
+ const res = await fetchManifestOnce(baseUrl, resolved, bust);
161
+ if (res.ok) return res.json();
162
+
163
+ const sentScopes = Object.keys(resolved);
164
+ if ((res.status === 401 || res.status === 403) && sentScopes.length > 0) {
165
+ console.warn(
166
+ `[arc] Manifest fetch ${res.status} with token(s) [${sentScopes.join(", ")}]; ` +
167
+ `clearing them and retrying anonymously.`,
168
+ );
169
+ clearPersistedTokens(sentScopes);
170
+ const retry = await fetchManifestOnce(baseUrl, {}, bust);
171
+ if (retry.ok) return retry.json();
172
+ throw new Error(`Failed to fetch module manifest: ${retry.status}`);
173
+ }
174
+
175
+ throw new Error(`Failed to fetch module manifest: ${res.status}`);
134
176
  }
135
177
 
136
178
  /** Track hashes of token-group bundles already imported so hot-swaps re-import on change. */
@@ -143,29 +185,81 @@ const importedGroupHashes = new Map<string, string>();
143
185
  const knownGroupModuleNames = new Set<string>();
144
186
 
145
187
  /**
146
- * Attempt a dynamic import. If it fails (network error, evaluation error,
147
- * stale signed URL), discard the token tied to this module's chunk and let
148
- * the next reload refetch a clean manifest. Without this, a single stale
149
- * token bricks the entire boot path because Promise.all rejects on first
150
- * failure.
188
+ * Attempt a dynamic import of a token-group bundle. Returns `true` on success,
189
+ * `false` on failure (network error, evaluation error, stale signed URL).
190
+ * Deliberately does NOT mutate tokens: recovery is orchestrated by the caller
191
+ * so a stale cached manifest can be refetched before we give up on the token.
192
+ * Never rejects, so a single failing group can't brick the whole boot path.
151
193
  */
152
- async function safeImportGroup(
194
+ async function tryImportGroup(
153
195
  baseUrl: string,
154
- groupName: string,
155
196
  group: BuildManifestGroup,
156
197
  bust?: string,
157
- ): Promise<void> {
198
+ ): Promise<boolean> {
158
199
  try {
159
200
  await import(/* @vite-ignore */ groupUrl(baseUrl, group, bust));
201
+ return true;
160
202
  } catch (err) {
203
+ console.error(`[arc] Failed to load group "${group.file}".`, err);
204
+ return false;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Import the named groups from `manifest`. On any failure — typically a 404/403
210
+ * from a stale cached manifest after a deploy — refetch a cache-busted manifest
211
+ * ONCE and retry only the still-failing groups with the fresh hashes/signed
212
+ * URLs. Only if a group still fails against the fresh manifest (genuinely broken
213
+ * bundle) or has vanished from it (access revoked) do we clear its token. This
214
+ * self-heals the "manifest error / token disappeared" symptom even when an
215
+ * intermediary cache/CDN ignores `no-cache`.
216
+ *
217
+ * Returns the manifest that actually satisfied the imports (the fresh one if a
218
+ * refetch happened), so callers compute their active-module set from it.
219
+ */
220
+ async function importGroupsWithRecovery(
221
+ baseUrl: string,
222
+ manifest: BuildManifest,
223
+ names: string[],
224
+ tokens?: Record<string, string>,
225
+ bust?: string,
226
+ ): Promise<BuildManifest> {
227
+ const first = await Promise.all(
228
+ names.map(async (name) => ({
229
+ name,
230
+ ok: await tryImportGroup(baseUrl, manifest.groups[name], bust),
231
+ })),
232
+ );
233
+ const failed = first.filter((r) => !r.ok).map((r) => r.name);
234
+ if (failed.length === 0) return manifest;
235
+
236
+ console.warn(
237
+ `[arc] ${failed.length} group(s) failed to load; refetching manifest to recover.`,
238
+ );
239
+ const fresh = await fetchManifest(baseUrl, tokens, String(Date.now()));
240
+ const freshBust = String(Date.now());
241
+ const retry = await Promise.all(
242
+ failed
243
+ .filter((name) => fresh.groups[name])
244
+ .map(async (name) => {
245
+ const group = fresh.groups[name];
246
+ importedGroupHashes.set(name, group.hash);
247
+ return { name, ok: await tryImportGroup(baseUrl, group, freshBust) };
248
+ }),
249
+ );
250
+
251
+ const unrecoverable = new Set(retry.filter((r) => !r.ok).map((r) => r.name));
252
+ // Groups absent from the fresh manifest = access revoked → unrecoverable too.
253
+ for (const name of failed) if (!fresh.groups[name]) unrecoverable.add(name);
254
+ for (const name of unrecoverable) {
161
255
  console.error(
162
- `[arc] Failed to load group "${groupName}". Clearing token; refresh to retry without it.`,
163
- err,
256
+ `[arc] Group "${name}" still failing after manifest refresh; clearing token.`,
164
257
  );
165
258
  if (typeof localStorage !== "undefined") {
166
- localStorage.removeItem(`arc:token:${groupName}`);
259
+ localStorage.removeItem(`arc:token:${name}`);
167
260
  }
168
261
  }
262
+ return fresh;
169
263
  }
170
264
 
171
265
  /**
@@ -177,14 +271,10 @@ export async function loadModules(
177
271
  ): Promise<BuildManifest> {
178
272
  const manifest = await fetchManifest(baseUrl, tokens);
179
273
 
180
- await Promise.all(
181
- Object.entries(manifest.groups).map(([name, group]) => {
182
- importedGroupHashes.set(name, group.hash);
183
- return safeImportGroup(baseUrl, name, group);
184
- }),
185
- );
274
+ const names = Object.keys(manifest.groups);
275
+ for (const name of names) importedGroupHashes.set(name, manifest.groups[name].hash);
186
276
 
187
- return manifest;
277
+ return importGroupsWithRecovery(baseUrl, manifest, names, tokens);
188
278
  }
189
279
 
190
280
  /**
@@ -199,24 +289,23 @@ export async function syncModules(
199
289
  ): Promise<BuildManifest> {
200
290
  const manifest = await fetchManifest(baseUrl, tokens);
201
291
 
202
- const toImport = Object.entries(manifest.groups).filter(
203
- ([name, g]) => importedGroupHashes.get(name) !== g.hash,
204
- );
292
+ const toImport = Object.entries(manifest.groups)
293
+ .filter(([name, g]) => importedGroupHashes.get(name) !== g.hash)
294
+ .map(([name]) => name);
205
295
 
296
+ // Recovery may refetch a fresh manifest; the active set must be computed from
297
+ // whatever manifest actually satisfied the imports.
298
+ let effective = manifest;
206
299
  if (toImport.length > 0) {
207
- await Promise.all(
208
- toImport.map(([name, group]) => {
209
- importedGroupHashes.set(name, group.hash);
210
- return safeImportGroup(baseUrl, name, group);
211
- }),
212
- );
300
+ for (const name of toImport) importedGroupHashes.set(name, manifest.groups[name].hash);
301
+ effective = await importGroupsWithRecovery(baseUrl, manifest, toImport, tokens);
213
302
  }
214
303
 
215
304
  // Compute active set: every registered module EXCEPT protected-group members
216
305
  // not present in the current manifest (e.g. after logout). Public modules,
217
306
  // never observed in a group, stay visible automatically.
218
307
  const currentlyAllowed = new Set<string>();
219
- for (const group of Object.values(manifest.groups)) {
308
+ for (const group of Object.values(effective.groups)) {
220
309
  for (const name of group.modules) {
221
310
  currentlyAllowed.add(name);
222
311
  knownGroupModuleNames.add(name);
@@ -229,7 +318,7 @@ export async function syncModules(
229
318
  }
230
319
  setActiveModules(active);
231
320
 
232
- return manifest;
321
+ return effective;
233
322
  }
234
323
 
235
324
  /**
@@ -246,10 +335,13 @@ export async function reloadModules(
246
335
  // Clear all modules — fresh code will re-register them
247
336
  clearModules();
248
337
 
338
+ // Dev full-reload: the cache-bust already guarantees fresh code on disk, so a
339
+ // failure here is a genuine build/eval error — log it, don't nuke tokens.
249
340
  await Promise.all(
250
- Object.entries(manifest.groups).map(([name, group]) =>
251
- safeImportGroup(baseUrl, name, group, bust),
252
- ),
341
+ Object.entries(manifest.groups).map(([name, group]) => {
342
+ importedGroupHashes.set(name, group.hash);
343
+ return tryImportGroup(baseUrl, group, bust);
344
+ }),
253
345
  );
254
346
 
255
347
  return manifest;
package/src/registry.ts CHANGED
@@ -59,6 +59,27 @@ export function unregisterModule(moduleId: string): void {
59
59
  notify();
60
60
  }
61
61
 
62
+ /**
63
+ * Czy ścieżka należy do strony zadeklarowanej w `.public({...})` któregokolwiek
64
+ * zarejestrowanego modułu. Źródłem prawdy jest rejestr — wrappery ochronne
65
+ * (np. ProtectedRoute aplikacji) NIE muszą hardcodować listy publicznych
66
+ * ścieżek. Dopasowanie: dokładne lub prefiks segmentowy
67
+ * (`/checkout` pokrywa `/checkout/success`); query/hash ignorowane.
68
+ */
69
+ export function isPublicPagePath(path: string): boolean {
70
+ const clean = path.split("?")[0].split("#")[0] || "/";
71
+ for (const mod of modules.values()) {
72
+ for (const fragment of mod.publicFragments ?? []) {
73
+ if (!fragment?.is?.("page")) continue;
74
+ const pagePath = (fragment as PageFragment).path;
75
+ if (!pagePath) continue;
76
+ if (clean === pagePath) return true;
77
+ if (pagePath !== "/" && clean.startsWith(`${pagePath}/`)) return true;
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+
62
83
  export function getModule(moduleId: string): ArcModule | undefined {
63
84
  return modules.get(moduleId);
64
85
  }
@@ -0,0 +1,120 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { loadModules } from "../src/module-loader";
3
+
4
+ /**
5
+ * Regression for "Failed to fetch module manifest: 401".
6
+ *
7
+ * The platform server rejects ANY request carrying an invalid token with 401
8
+ * (global auth gate) — including /api/modules. A stale token in localStorage
9
+ * (e.g. minted with a rotated secret) therefore bricks the whole boot. The
10
+ * loader must self-heal: drop the rejected token(s) and refetch the manifest
11
+ * anonymously instead of throwing.
12
+ */
13
+
14
+ // --- Minimal in-memory localStorage --------------------------------------
15
+ class MemoryStorage {
16
+ private map = new Map<string, string>();
17
+ get length() {
18
+ return this.map.size;
19
+ }
20
+ key(i: number) {
21
+ return [...this.map.keys()][i] ?? null;
22
+ }
23
+ getItem(k: string) {
24
+ return this.map.get(k) ?? null;
25
+ }
26
+ setItem(k: string, v: string) {
27
+ this.map.set(k, v);
28
+ }
29
+ removeItem(k: string) {
30
+ this.map.delete(k);
31
+ }
32
+ clear() {
33
+ this.map.clear();
34
+ }
35
+ }
36
+
37
+ // A JWT-shaped string with no `exp` claim so getAllPersistedTokens keeps it.
38
+ const dummyJwt = "aaa.eyJ0b2tlbk5hbWUiOiJ1c2VyIn0.bbb";
39
+ const emptyManifest = {
40
+ initial: { file: "initial.abc.js", hash: "abc" },
41
+ groups: {},
42
+ sharedChunks: [],
43
+ stylesHash: "s",
44
+ buildTime: "t",
45
+ };
46
+
47
+ let originalFetch: typeof globalThis.fetch;
48
+ let originalLocalStorage: any;
49
+ let calls: { url: string; hasAuth: boolean }[];
50
+
51
+ beforeEach(() => {
52
+ originalFetch = globalThis.fetch;
53
+ originalLocalStorage = (globalThis as any).localStorage;
54
+ (globalThis as any).localStorage = new MemoryStorage();
55
+ calls = [];
56
+ });
57
+
58
+ afterEach(() => {
59
+ globalThis.fetch = originalFetch;
60
+ (globalThis as any).localStorage = originalLocalStorage;
61
+ });
62
+
63
+ describe("manifest fetch — 401 self-heal", () => {
64
+ it("drops the rejected token and retries anonymously", async () => {
65
+ localStorage.setItem("arc:token:user", dummyJwt);
66
+
67
+ // First (tokened) request → 401; anonymous retry → 200 manifest.
68
+ globalThis.fetch = (async (input: any, init: any) => {
69
+ const url = String(input);
70
+ const hasAuth = Boolean(new Headers(init?.headers).get("Authorization"));
71
+ calls.push({ url, hasAuth });
72
+ if (hasAuth) {
73
+ return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
74
+ }
75
+ return new Response(JSON.stringify(emptyManifest), { status: 200 });
76
+ }) as any;
77
+
78
+ const manifest = await loadModules("http://x");
79
+
80
+ // Recovered: resolved with the manifest, did not throw.
81
+ expect(manifest.groups).toEqual({});
82
+ // Two requests: one with the token, then the anonymous retry.
83
+ expect(calls).toHaveLength(2);
84
+ expect(calls[0].hasAuth).toBe(true);
85
+ expect(calls[1].hasAuth).toBe(false);
86
+ // The rejected token was cleared from storage.
87
+ expect(localStorage.getItem("arc:token:user")).toBeNull();
88
+ });
89
+
90
+ it("does not clear tokens or retry on a successful fetch", async () => {
91
+ localStorage.setItem("arc:token:user", dummyJwt);
92
+
93
+ globalThis.fetch = (async (input: any, init: any) => {
94
+ const hasAuth = Boolean(new Headers(init?.headers).get("Authorization"));
95
+ calls.push({ url: String(input), hasAuth });
96
+ return new Response(JSON.stringify(emptyManifest), { status: 200 });
97
+ }) as any;
98
+
99
+ await loadModules("http://x");
100
+
101
+ expect(calls).toHaveLength(1);
102
+ expect(calls[0].hasAuth).toBe(true);
103
+ expect(localStorage.getItem("arc:token:user")).toBe(dummyJwt);
104
+ });
105
+
106
+ it("propagates a non-auth error (e.g. 500) without clearing tokens", async () => {
107
+ localStorage.setItem("arc:token:user", dummyJwt);
108
+
109
+ globalThis.fetch = (async (input: any, init: any) => {
110
+ const hasAuth = Boolean(new Headers(init?.headers).get("Authorization"));
111
+ calls.push({ url: String(input), hasAuth });
112
+ return new Response("boom", { status: 500 });
113
+ }) as any;
114
+
115
+ await expect(loadModules("http://x")).rejects.toThrow(/500/);
116
+ expect(calls).toHaveLength(1);
117
+ // A server error is not a token problem — keep the token.
118
+ expect(localStorage.getItem("arc:token:user")).toBe(dummyJwt);
119
+ });
120
+ });