@arcote.tech/platform 0.7.0 → 0.7.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,34 +1,24 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
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",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
- ".": {
12
- "types": "./src/index.ts",
13
- "bun": "./src/index.server.ts",
14
- "node": "./src/index.server.ts",
15
- "browser": "./src/index.ts",
16
- "default": "./src/index.ts"
17
- },
18
- "./server": {
19
- "types": "./src/index.server.ts",
20
- "default": "./src/index.server.ts"
21
- }
11
+ ".": "./src/index.ts"
22
12
  },
23
13
  "scripts": {
24
14
  "type-check": "tsc --noEmit"
25
15
  },
26
16
  "dependencies": {
27
- "@arcote.tech/arc-ds": "^0.7.0",
28
- "@arcote.tech/arc-react": "^0.7.0"
17
+ "@arcote.tech/arc-ds": "^0.7.1",
18
+ "@arcote.tech/arc-react": "^0.7.1"
29
19
  },
30
20
  "peerDependencies": {
31
- "@arcote.tech/arc": "^0.7.0",
21
+ "@arcote.tech/arc": "^0.7.1",
32
22
  "@lingui/core": "^5.0.0",
33
23
  "@lingui/react": "^5.0.0",
34
24
  "framer-motion": "^12.0.0",
package/src/index.ts CHANGED
@@ -97,6 +97,7 @@ export { loadModules, reloadModules, syncModules, useModuleLoader } from "./modu
97
97
  export type { ModuleLoaderState, ModuleManifest } from "./module-loader";
98
98
  export { PlatformApp } from "./platform-app";
99
99
  export type { PlatformAppProps } from "./platform-app";
100
+ export { startApp } from "./start-app";
100
101
  export type { PlatformConfig, PlatformStorageConfig } from "./registry";
101
102
  export {
102
103
  PlatformModelProvider,
@@ -113,6 +114,7 @@ export type {
113
114
  ArcLayoutComponent,
114
115
  ArcModule,
115
116
  BuildManifest,
117
+ BuildManifestGroup,
116
118
  BuiltModule,
117
119
  ContextElementFragment,
118
120
  ModuleAccess,
@@ -1,5 +1,5 @@
1
1
  import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
2
- import type { BuildManifest, ModuleDescriptor } from "./types";
2
+ import type { BuildManifest, BuildManifestGroup } from "./types";
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
 
5
5
  /** @deprecated Use BuildManifest from "./types" */
@@ -8,35 +8,60 @@ export type ModuleManifest = BuildManifest;
8
8
  export type ModuleLoaderState = "loading" | "ready" | "error";
9
9
 
10
10
  /**
11
- * URL for a module's JS file with cache-bust based on its content hash.
12
- * When a module's bytes change, hash changes URL changes ES module cache invalidated.
13
- * For explicit full reloads, pass `bust` (e.g. timestamp) to override.
14
- *
15
- * Server-filtered descriptors carry a signed `mod.url` (chunk-aware HMAC) for
16
- * non-public chunks. Otherwise the path is `/modules/<chunk>/<file>` — public
17
- * chunks are served without a signature.
11
+ * URL for a token-group bundle. Server-filtered manifests carry a signed
12
+ * `group.url` (HMAC, 1h TTL); we use that directly. `bust` overrides the
13
+ * content-hash cache key during dev full-reloads.
18
14
  */
19
- function moduleUrl(baseUrl: string, mod: ModuleDescriptor, bust?: string): string {
20
- const base = mod.url
21
- ? `${baseUrl}${mod.url}`
22
- : `${baseUrl}/modules/${mod.chunk}/${mod.file}`;
15
+ function groupUrl(baseUrl: string, group: BuildManifestGroup, bust?: string): string {
16
+ const base = group.url
17
+ ? `${baseUrl}${group.url}`
18
+ : `${baseUrl}/browser/${group.file}`;
23
19
  const busterKey = bust ? "t" : "v";
24
- const busterVal = bust ?? mod.hash;
20
+ const busterVal = bust ?? group.hash;
25
21
  if (!busterVal) return base;
26
22
  return `${base}${base.includes("?") ? "&" : "?"}${busterKey}=${busterVal}`;
27
23
  }
28
24
 
29
- /** Read all persisted arc tokens from localStorage */
25
+ /**
26
+ * Decode a JWT payload (base64url) without verifying signature. Returns
27
+ * null if the token is malformed. Used purely to read the `exp` claim so
28
+ * we can drop expired tokens client-side before they hit the server.
29
+ */
30
+ function decodeJwtPayload(jwt: string): { exp?: number } | null {
31
+ const parts = jwt.split(".");
32
+ if (parts.length !== 3) return null;
33
+ try {
34
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
35
+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
36
+ return JSON.parse(atob(padded));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Read all persisted arc tokens from localStorage, dropping any whose `exp`
44
+ * claim is in the past. Stale tokens are removed from localStorage so they
45
+ * don't keep ghosting in subsequent reads.
46
+ */
30
47
  function getAllPersistedTokens(): Record<string, string> {
31
48
  if (typeof localStorage === "undefined") return {};
32
49
  const tokens: Record<string, string> = {};
50
+ const stale: string[] = [];
51
+ const nowSec = Math.floor(Date.now() / 1000);
33
52
  for (let i = 0; i < localStorage.length; i++) {
34
53
  const key = localStorage.key(i);
35
- if (key?.startsWith("arc:token:")) {
36
- const raw = localStorage.getItem(key);
37
- if (raw) tokens[key.slice(10)] = raw;
54
+ if (!key?.startsWith("arc:token:")) continue;
55
+ const raw = localStorage.getItem(key);
56
+ if (!raw) continue;
57
+ const payload = decodeJwtPayload(raw);
58
+ if (payload?.exp && payload.exp < nowSec) {
59
+ stale.push(key);
60
+ continue;
38
61
  }
62
+ tokens[key.slice(10)] = raw;
39
63
  }
64
+ for (const key of stale) localStorage.removeItem(key);
40
65
  return tokens;
41
66
  }
42
67
 
@@ -70,8 +95,40 @@ async function fetchManifest(
70
95
  return res.json();
71
96
  }
72
97
 
73
- /** Track hashes of modules we've already imported so hot-swaps re-import on change. */
74
- const importedModuleHashes = new Map<string, string>();
98
+ /** Track hashes of token-group bundles already imported so hot-swaps re-import on change. */
99
+ const importedGroupHashes = new Map<string, string>();
100
+
101
+ /** Names of every module ever observed inside a protected group. Used to compute
102
+ * the active-modules set: hide previously-loaded group members that the caller
103
+ * no longer has access to. Public modules (from the initial bundle) are never
104
+ * in this set, so they stay visible across token changes. */
105
+ const knownGroupModuleNames = new Set<string>();
106
+
107
+ /**
108
+ * Attempt a dynamic import. If it fails (network error, evaluation error,
109
+ * stale signed URL), discard the token tied to this module's chunk and let
110
+ * the next reload refetch a clean manifest. Without this, a single stale
111
+ * token bricks the entire boot path because Promise.all rejects on first
112
+ * failure.
113
+ */
114
+ async function safeImportGroup(
115
+ baseUrl: string,
116
+ groupName: string,
117
+ group: BuildManifestGroup,
118
+ bust?: string,
119
+ ): Promise<void> {
120
+ try {
121
+ await import(/* @vite-ignore */ groupUrl(baseUrl, group, bust));
122
+ } catch (err) {
123
+ console.error(
124
+ `[arc] Failed to load group "${groupName}". Clearing token; refresh to retry without it.`,
125
+ err,
126
+ );
127
+ if (typeof localStorage !== "undefined") {
128
+ localStorage.removeItem(`arc:token:${groupName}`);
129
+ }
130
+ }
131
+ }
75
132
 
76
133
  /**
77
134
  * Load all modules from manifest. Each module auto-registers via module().build().
@@ -83,9 +140,9 @@ export async function loadModules(
83
140
  const manifest = await fetchManifest(baseUrl, tokens);
84
141
 
85
142
  await Promise.all(
86
- manifest.modules.map((mod) => {
87
- importedModuleHashes.set(mod.name, mod.hash);
88
- return import(/* @vite-ignore */ moduleUrl(baseUrl, mod));
143
+ Object.entries(manifest.groups).map(([name, group]) => {
144
+ importedGroupHashes.set(name, group.hash);
145
+ return safeImportGroup(baseUrl, name, group);
89
146
  }),
90
147
  );
91
148
 
@@ -104,25 +161,35 @@ export async function syncModules(
104
161
  ): Promise<BuildManifest> {
105
162
  const manifest = await fetchManifest(baseUrl, tokens);
106
163
 
107
- const manifestNames = new Set(manifest.modules.map((m) => m.name));
108
-
109
- // A module needs reimporting if it's new OR its hash changed since last import
110
- const toImport = manifest.modules.filter((m) => {
111
- const prevHash = importedModuleHashes.get(m.name);
112
- return prevHash !== m.hash;
113
- });
164
+ const toImport = Object.entries(manifest.groups).filter(
165
+ ([name, g]) => importedGroupHashes.get(name) !== g.hash,
166
+ );
114
167
 
115
168
  if (toImport.length > 0) {
116
169
  await Promise.all(
117
- toImport.map((mod) => {
118
- importedModuleHashes.set(mod.name, mod.hash);
119
- return import(/* @vite-ignore */ moduleUrl(baseUrl, mod));
170
+ toImport.map(([name, group]) => {
171
+ importedGroupHashes.set(name, group.hash);
172
+ return safeImportGroup(baseUrl, name, group);
120
173
  }),
121
174
  );
122
175
  }
123
176
 
124
- // Set active filter only modules in the manifest are visible
125
- setActiveModules(manifestNames);
177
+ // Compute active set: every registered module EXCEPT protected-group members
178
+ // not present in the current manifest (e.g. after logout). Public modules,
179
+ // never observed in a group, stay visible automatically.
180
+ const currentlyAllowed = new Set<string>();
181
+ for (const group of Object.values(manifest.groups)) {
182
+ for (const name of group.modules) {
183
+ currentlyAllowed.add(name);
184
+ knownGroupModuleNames.add(name);
185
+ }
186
+ }
187
+ const active = new Set<string>();
188
+ for (const m of getAllRegisteredModules()) {
189
+ if (knownGroupModuleNames.has(m.name) && !currentlyAllowed.has(m.name)) continue;
190
+ active.add(m.name);
191
+ }
192
+ setActiveModules(active);
126
193
 
127
194
  return manifest;
128
195
  }
@@ -142,8 +209,8 @@ export async function reloadModules(
142
209
  clearModules();
143
210
 
144
211
  await Promise.all(
145
- manifest.modules.map(
146
- (mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod, bust)),
212
+ Object.entries(manifest.groups).map(([name, group]) =>
213
+ safeImportGroup(baseUrl, name, group, bust),
147
214
  ),
148
215
  );
149
216
 
package/src/registry.ts CHANGED
@@ -92,35 +92,28 @@ function notifyContext(): void {
92
92
  }
93
93
 
94
94
  /** Set the active Arc context (called by arc() or module().build()).
95
- * If a context is already set, merges new elements into it
96
- * while preserving the ArcContext instance (with .get() method). */
95
+ * If a context is already set, merges new elements into it.
96
+ *
97
+ * CRITICAL: must mutate the existing ArcContext in place (push to .elements,
98
+ * set in .elementMap) — NOT construct a new instance. Mutation is the
99
+ * contract relied on by PlatformApp, which captures `getContext()` into a
100
+ * ref at first render and assumes that reference stays valid as additional
101
+ * modules register later (e.g. token-gated chunks loaded after login). If
102
+ * we returned a fresh context object, the ref would go stale and lookups
103
+ * like `model.context.get("strategyConsultation")` would yield undefined
104
+ * for any module registered after the model was created. */
97
105
  export function setContext(ctx: any): void {
98
106
  if (currentContext && ctx && currentContext !== ctx) {
99
- // Merge: append new elements that aren't already present
100
107
  const existingNames = new Set(
101
108
  currentContext.elements.map((e: any) => e.name ?? e.id ?? e),
102
109
  );
103
- const newElements = ctx.elements.filter(
104
- (e: any) => !existingNames.has(e.name ?? e.id ?? e),
105
- );
106
- if (newElements.length > 0) {
107
- // Rebuild using the context constructor to preserve .get() and other methods
108
- const allElements = [...currentContext.elements, ...newElements];
109
- const Ctor = currentContext.constructor;
110
- if (typeof Ctor === "function" && Ctor !== Object) {
111
- currentContext = new Ctor(allElements);
112
- } else {
113
- // Fallback: create context-like object with get() method
114
- const elementMap = new Map(
115
- allElements.map((e: any) => [e.name, e]),
116
- );
117
- currentContext = {
118
- elements: allElements,
119
- elementMap,
120
- get(name: string) {
121
- return elementMap.get(name);
122
- },
123
- };
110
+ for (const el of ctx.elements) {
111
+ const key = el.name ?? el.id ?? el;
112
+ if (existingNames.has(key)) continue;
113
+ existingNames.add(key);
114
+ currentContext.elements.push(el);
115
+ if (typeof el.name === "string" && currentContext.elementMap) {
116
+ currentContext.elementMap.set(el.name, el);
124
117
  }
125
118
  }
126
119
  } else {
@@ -0,0 +1,17 @@
1
+ import { createElement } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { PlatformApp } from "./platform-app";
4
+
5
+ /**
6
+ * Mount the platform app into the DOM. Bundled inside the platform module so
7
+ * that the single Bun.build pass keeps react + react-dom in ONE shared chunk
8
+ * (the one platform lives in). If the host HTML entry imported react/react-dom
9
+ * directly, Bun would copy them into the entry chunk alongside the existing
10
+ * platform-chunk copy — producing two React instances and the classic
11
+ * "Invalid hook call" crash.
12
+ */
13
+ export function startApp(elementId: string): void {
14
+ const el = document.getElementById(elementId);
15
+ if (!el) throw new Error(`startApp: #${elementId} not found`);
16
+ createRoot(el).render(createElement(PlatformApp));
17
+ }
package/src/types.ts CHANGED
@@ -115,13 +115,37 @@ export interface ModuleDescriptor {
115
115
  readonly url?: string;
116
116
  }
117
117
 
118
- /** Build manifest written to .arc/platform/modules/manifest.json. */
118
+ /** Per-group entry in the build manifest. */
119
+ export interface BuildManifestGroup {
120
+ /** Filename relative to `/browser/` static root. */
121
+ readonly file: string;
122
+ readonly hash: string;
123
+ /** Module names this group registers when loaded. */
124
+ readonly modules: readonly string[];
125
+ /** Signed URL — server fills this for protected groups when filtering per request. */
126
+ readonly url?: string;
127
+ }
128
+
129
+ /** Build manifest written to .arc/platform/manifest.json. */
119
130
  export interface BuildManifest {
120
- readonly modules: readonly ModuleDescriptor[];
121
- /** All chunk group names present in this build (sorted). Always includes `"public"`. */
122
- readonly chunks: readonly string[];
123
- /** sha256 hex over all shell bundle outputs concatenated. */
124
- readonly shellHash: string;
131
+ /**
132
+ * Single bundle with all public-chunk modules + framework bootstrap.
133
+ * Loaded eagerly on every page. Filename is content-addressed.
134
+ */
135
+ readonly initial: {
136
+ readonly file: string;
137
+ readonly hash: string;
138
+ };
139
+ /**
140
+ * Token-gated groups keyed by `token.name`. One bundle per group containing
141
+ * all that group's modules. Loaded lazily after the user obtains the token.
142
+ */
143
+ readonly groups: Readonly<Record<string, BuildManifestGroup>>;
144
+ /**
145
+ * Shared chunks auto-emitted by Bun.build splitting (workspace ctx,
146
+ * framework, common deps). Public, unsigned (filename is content-hashed).
147
+ */
148
+ readonly sharedChunks: readonly string[];
125
149
  /** sha256 hex over styles.css (+ theme.css if present). */
126
150
  readonly stylesHash: string;
127
151
  readonly buildTime: string;
@@ -1,84 +0,0 @@
1
- // Arc Platform — server entry (bun/node runtime)
2
- //
3
- // Subset of the public API that is safe to import in a server context:
4
- // pure data + functions, no React, no DOM, no JSX runtime imports at top
5
- // level. Used by:
6
- // - access-extractor subprocess (PRE-bundle discovery of protectedBy rules)
7
- // - server bundles per context package (resolve @arcote.tech/platform
8
- // through Bun's `bun` export condition)
9
- //
10
- // Shares the same `registry` module instance with the browser entry — both
11
- // reference the SAME ./registry file, so `module().build()` in user code
12
- // and `getAllModuleAccess()` in extractor observe a single source of truth.
13
-
14
- // Module system
15
- export {
16
- module,
17
- page,
18
- wrapper,
19
- slot,
20
- contextElement,
21
- contextFragments,
22
- } from "./arc";
23
- /** @deprecated Use module() instead */
24
- export { arc } from "./arc";
25
-
26
- // Registry
27
- export {
28
- clearModules,
29
- clearRegistry,
30
- forceRegisterModule,
31
- getAllFragments,
32
- getAllModuleAccess,
33
- getAllModules,
34
- getAllRegisteredModules,
35
- getContext,
36
- getModuleAccess,
37
- getContextElementFragments,
38
- getDefaultLayout,
39
- getModule,
40
- getPageByPath,
41
- getPlatformConfig,
42
- getPageFragments,
43
- getSlotFragments,
44
- getVariantOverrides,
45
- getWrapperFragments,
46
- registerModule,
47
- setContext,
48
- setDefaultLayout,
49
- setPlatformConfig,
50
- setVariantOverrides,
51
- subscribe,
52
- subscribeContext,
53
- unregisterModule,
54
- } from "./registry";
55
-
56
- export type { PlatformConfig, PlatformStorageConfig } from "./registry";
57
-
58
- // Types
59
- export type {
60
- ArcComponent,
61
- ArcFactory,
62
- ArcFactoryMethods,
63
- ArcFragment,
64
- ArcLayoutComponent,
65
- ArcModule,
66
- BuildManifest,
67
- BuiltModule,
68
- ContextElementFragment,
69
- ModuleAccess,
70
- ModuleAccessRule,
71
- ModuleDescriptor,
72
- ExtractPages,
73
- FragmentOrdering,
74
- PageFragment,
75
- PageOptions,
76
- PageShellProps,
77
- PublicArcFragment,
78
- PublicPaths,
79
- SlotFragment,
80
- SlotId,
81
- SlotOptions,
82
- WrapperFragment,
83
- WrapperOptions,
84
- } from "./types";