@arcote.tech/platform 0.6.2 → 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,28 +1,24 @@
1
1
  {
2
2
  "name": "@arcote.tech/platform",
3
3
  "type": "module",
4
- "version": "0.6.2",
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
- "import": "./src/index.ts",
14
- "default": "./src/index.ts"
15
- }
11
+ ".": "./src/index.ts"
16
12
  },
17
13
  "scripts": {
18
14
  "type-check": "tsc --noEmit"
19
15
  },
20
16
  "dependencies": {
21
- "@arcote.tech/arc-ds": "^0.6.2",
22
- "@arcote.tech/arc-react": "^0.6.2"
17
+ "@arcote.tech/arc-ds": "^0.7.1",
18
+ "@arcote.tech/arc-react": "^0.7.1"
23
19
  },
24
20
  "peerDependencies": {
25
- "@arcote.tech/arc": "^0.6.2",
21
+ "@arcote.tech/arc": "^0.7.1",
26
22
  "@lingui/core": "^5.0.0",
27
23
  "@lingui/react": "^5.0.0",
28
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,29 +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.
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.
14
14
  */
15
- function moduleUrl(baseUrl: string, mod: ModuleDescriptor, bust?: string): string {
16
- const base = mod.url ?? `${baseUrl}/modules/${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}`;
17
19
  const busterKey = bust ? "t" : "v";
18
- const busterVal = bust ?? mod.hash;
20
+ const busterVal = bust ?? group.hash;
19
21
  if (!busterVal) return base;
20
22
  return `${base}${base.includes("?") ? "&" : "?"}${busterKey}=${busterVal}`;
21
23
  }
22
24
 
23
- /** 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
+ */
24
47
  function getAllPersistedTokens(): Record<string, string> {
25
48
  if (typeof localStorage === "undefined") return {};
26
49
  const tokens: Record<string, string> = {};
50
+ const stale: string[] = [];
51
+ const nowSec = Math.floor(Date.now() / 1000);
27
52
  for (let i = 0; i < localStorage.length; i++) {
28
53
  const key = localStorage.key(i);
29
- if (key?.startsWith("arc:token:")) {
30
- const raw = localStorage.getItem(key);
31
- 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;
32
61
  }
62
+ tokens[key.slice(10)] = raw;
33
63
  }
64
+ for (const key of stale) localStorage.removeItem(key);
34
65
  return tokens;
35
66
  }
36
67
 
@@ -64,8 +95,40 @@ async function fetchManifest(
64
95
  return res.json();
65
96
  }
66
97
 
67
- /** Track hashes of modules we've already imported so hot-swaps re-import on change. */
68
- 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
+ }
69
132
 
70
133
  /**
71
134
  * Load all modules from manifest. Each module auto-registers via module().build().
@@ -77,9 +140,9 @@ export async function loadModules(
77
140
  const manifest = await fetchManifest(baseUrl, tokens);
78
141
 
79
142
  await Promise.all(
80
- manifest.modules.map((mod) => {
81
- importedModuleHashes.set(mod.name, mod.hash);
82
- 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);
83
146
  }),
84
147
  );
85
148
 
@@ -98,25 +161,35 @@ export async function syncModules(
98
161
  ): Promise<BuildManifest> {
99
162
  const manifest = await fetchManifest(baseUrl, tokens);
100
163
 
101
- const manifestNames = new Set(manifest.modules.map((m) => m.name));
102
-
103
- // A module needs reimporting if it's new OR its hash changed since last import
104
- const toImport = manifest.modules.filter((m) => {
105
- const prevHash = importedModuleHashes.get(m.name);
106
- return prevHash !== m.hash;
107
- });
164
+ const toImport = Object.entries(manifest.groups).filter(
165
+ ([name, g]) => importedGroupHashes.get(name) !== g.hash,
166
+ );
108
167
 
109
168
  if (toImport.length > 0) {
110
169
  await Promise.all(
111
- toImport.map((mod) => {
112
- importedModuleHashes.set(mod.name, mod.hash);
113
- return import(/* @vite-ignore */ moduleUrl(baseUrl, mod));
170
+ toImport.map(([name, group]) => {
171
+ importedGroupHashes.set(name, group.hash);
172
+ return safeImportGroup(baseUrl, name, group);
114
173
  }),
115
174
  );
116
175
  }
117
176
 
118
- // Set active filter only modules in the manifest are visible
119
- 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);
120
193
 
121
194
  return manifest;
122
195
  }
@@ -136,8 +209,8 @@ export async function reloadModules(
136
209
  clearModules();
137
210
 
138
211
  await Promise.all(
139
- manifest.modules.map(
140
- (mod) => import(/* @vite-ignore */ moduleUrl(baseUrl, mod, bust)),
212
+ Object.entries(manifest.groups).map(([name, group]) =>
213
+ safeImportGroup(baseUrl, name, group, bust),
141
214
  ),
142
215
  );
143
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
@@ -100,18 +100,52 @@ export interface ModuleAccess {
100
100
  }
101
101
 
102
102
  export interface ModuleDescriptor {
103
+ /** Filename within the chunk directory, e.g. `myapp.js`. */
103
104
  readonly file: string;
104
105
  readonly name: string;
106
+ /**
107
+ * Chunk group this module belongs to. `"public"` for anonymous-accessible
108
+ * modules; any other value matches a `token.name` from `protectedBy(...)`.
109
+ * Physical layout: `.arc/platform/modules/<chunk>/<file>`.
110
+ */
111
+ readonly chunk: string;
105
112
  /** sha256 hex of the bundled .js content — used by deploy diff and client cache-bust. */
106
113
  readonly hash: string;
114
+ /** Signed URL — server fills this for non-public modules when filtering manifest per request. */
107
115
  readonly url?: string;
108
116
  }
109
117
 
110
- /** 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. */
111
130
  export interface BuildManifest {
112
- readonly modules: readonly ModuleDescriptor[];
113
- /** sha256 hex over all shell bundle outputs concatenated. */
114
- 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[];
115
149
  /** sha256 hex over styles.css (+ theme.css if present). */
116
150
  readonly stylesHash: string;
117
151
  readonly buildTime: string;