@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 +5 -9
- package/src/index.ts +2 -0
- package/src/module-loader.ts +103 -30
- package/src/registry.ts +17 -24
- package/src/start-app.ts +17 -0
- package/src/types.ts +38 -4
package/package.json
CHANGED
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/platform",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "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
|
-
"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.
|
|
22
|
-
"@arcote.tech/arc-react": "^0.
|
|
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.
|
|
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,
|
package/src/module-loader.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { clearModules, getAllRegisteredModules, getContext, setActiveModules } from "./registry";
|
|
2
|
-
import type { BuildManifest,
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
16
|
-
const base =
|
|
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 ??
|
|
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
|
-
/**
|
|
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
|
-
|
|
31
|
-
|
|
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
|
|
68
|
-
const
|
|
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.
|
|
81
|
-
|
|
82
|
-
return
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
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((
|
|
112
|
-
|
|
113
|
-
return
|
|
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
|
-
//
|
|
119
|
-
|
|
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.
|
|
140
|
-
(
|
|
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
|
-
*
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 {
|
package/src/start-app.ts
ADDED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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;
|