@fluid-app/fluid-cli-portal 0.1.52 → 0.1.53
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/README.md +3 -0
- package/dist/build-manifest.d.mts +91 -0
- package/dist/build-manifest.d.mts.map +1 -0
- package/dist/build-manifest.mjs +180 -0
- package/dist/build-manifest.mjs.map +1 -0
- package/dist/index.d.mts +234 -519
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1467 -573
- package/dist/index.mjs.map +1 -1
- package/dist/portal-dev-plugin-ByYelgFg.mjs +232 -0
- package/dist/portal-dev-plugin-ByYelgFg.mjs.map +1 -0
- package/dist/{pull-XOsFaqz2.mjs → pull-C3yj9L07.mjs} +44 -16
- package/dist/pull-C3yj9L07.mjs.map +1 -0
- package/dist/vite-plugin.d.mts +12 -56
- package/dist/vite-plugin.d.mts.map +1 -1
- package/dist/vite-plugin.mjs +3 -2
- package/package.json +8 -1
- package/templates/base/.gitignore.template +6 -0
- package/templates/base/.oxlintrc.json +1 -2
- package/templates/base/skills/fluid-portal-authoring/SKILL.md +126 -10
- package/templates/starter/.env.example +4 -3
- package/templates/starter/README.md.template +14 -1
- package/dist/portal-dev-plugin-BHE8Z4yr.mjs +0 -289
- package/dist/portal-dev-plugin-BHE8Z4yr.mjs.map +0 -1
- package/dist/pull-XOsFaqz2.mjs.map +0 -1
|
@@ -64,11 +64,115 @@ Guidelines:
|
|
|
64
64
|
|
|
65
65
|
A page = a **screen** (a `component_tree` of widgets) + a **navigation item** that points at it. A screen with no nav item is unreachable; a nav item whose screen reference doesn't resolve is silently dropped in local preview and refused at push. Both live in `portal/`.
|
|
66
66
|
|
|
67
|
-
1. Create `portal/screens/<slug>.json`: `{ "name": "Rewards", "component_tree": [ ... ] }`. Give every node an `id` and a widget `type` from the catalog below.
|
|
68
|
-
2. Add a navigation item to each profile that should see it: `{ "label": "Rewards", "slug": "rewards", "
|
|
69
|
-
3. Link to it with a `
|
|
67
|
+
1. Create `portal/screens/<slug>.json`: `{ "name": "Rewards", "component_tree": [ ... ] }`. Give every node an `id` and a widget `type` from the catalog below. The **filename is the resource slug**; an inline `slug` field is optional and is not what cross-resource resolution uses.
|
|
68
|
+
2. Add a navigation item to each profile that should see it. Local JSON uses the screen **slug**, not a numeric backend id: `{ "label": "Rewards", "slug": "rewards", "screen": "rewards", "source": "user", "position": 2, "children": [] }`. Use `children[]` for a nested group. Add it to the profile's `mobile_navigation` too for mobile.
|
|
69
|
+
3. Link to it with a `LinkWidget` in screen mode. Use the screen slug rather
|
|
70
|
+
than a URL path so the portal shell handles navigation. `LinkWidget` props for the example above:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"linkType": "screen",
|
|
75
|
+
"screenSlug": "rewards"
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Carousel slides use their separate `buttonLink` property; set that to the
|
|
80
|
+
path `/rewards`.
|
|
81
|
+
|
|
70
82
|
4. Preview on the profile local dev serves (default/first), then push and activate a version to go live.
|
|
71
83
|
|
|
84
|
+
### Bootstrapping a brand-new empty definition
|
|
85
|
+
|
|
86
|
+
`fluid portal pull` legitimately returns zero screens/themes/navigations/profiles for a newly created portal. Do not search for hidden starter resources and do not move the routes into `src/`. Create a complete minimal definition locally:
|
|
87
|
+
|
|
88
|
+
`portal/screens/home.json`
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"name": "Home",
|
|
93
|
+
"component_tree": [
|
|
94
|
+
{
|
|
95
|
+
"id": "ContainerWidget-home-root",
|
|
96
|
+
"type": "ContainerWidget",
|
|
97
|
+
"props": {
|
|
98
|
+
"background": {
|
|
99
|
+
"type": "solid",
|
|
100
|
+
"color": "background"
|
|
101
|
+
},
|
|
102
|
+
"gapSize": "md",
|
|
103
|
+
"padding": 4,
|
|
104
|
+
"children": [
|
|
105
|
+
{
|
|
106
|
+
"id": "TextWidget-home-welcome",
|
|
107
|
+
"type": "TextWidget",
|
|
108
|
+
"columnIndex": 0,
|
|
109
|
+
"props": {
|
|
110
|
+
"title": "Welcome",
|
|
111
|
+
"titleColor": "foreground",
|
|
112
|
+
"description": "Your portal is ready.",
|
|
113
|
+
"descriptionColor": "foreground",
|
|
114
|
+
"background": {
|
|
115
|
+
"type": "solid",
|
|
116
|
+
"color": "background"
|
|
117
|
+
},
|
|
118
|
+
"padding": 4,
|
|
119
|
+
"borderRadius": "md"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`portal/navigations/main.json` (create a parallel `mobile.json` with `"platform": "mobile"`)
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"name": "Main Navigation",
|
|
134
|
+
"platform": "web",
|
|
135
|
+
"navigation_items": [
|
|
136
|
+
{
|
|
137
|
+
"label": "Home",
|
|
138
|
+
"slug": "home",
|
|
139
|
+
"icon": "home",
|
|
140
|
+
"position": 1,
|
|
141
|
+
"screen": "home",
|
|
142
|
+
"source": "user",
|
|
143
|
+
"parent_id": null,
|
|
144
|
+
"children": []
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`portal/profiles/default.json`
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"name": "Default Profile",
|
|
155
|
+
"default": true,
|
|
156
|
+
"permissions": {
|
|
157
|
+
"countries": [],
|
|
158
|
+
"ranks": [],
|
|
159
|
+
"roles": [],
|
|
160
|
+
"platform": []
|
|
161
|
+
},
|
|
162
|
+
"navigation": "main",
|
|
163
|
+
"mobile_navigation": "mobile",
|
|
164
|
+
"themes": []
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Create additional screen files and add their slug to both navigations before previewing. `fluid portal push` creates files listed as new, records their backend mappings, then creates navigations and profiles in dependency order. “No mapping found” applies to a file incorrectly treated as changed/deleted, not to a valid new file.
|
|
169
|
+
|
|
170
|
+
If you create `portal/themes/<slug>.json`, it must contain the complete
|
|
171
|
+
`{ "name", "active", "config" }` shape. A theme with only `name` and
|
|
172
|
+
`active` can appear harmless in local preview but the API rejects it. Copy a
|
|
173
|
+
pulled theme's full `config` object and then change its `id`, `name`, and token
|
|
174
|
+
values; do not invent a partial config.
|
|
175
|
+
|
|
72
176
|
## Choosing a widget (recommend built-ins before custom code)
|
|
73
177
|
|
|
74
178
|
Use the `type` value in a `component_tree` node. Reach for what already exists:
|
|
@@ -77,8 +181,11 @@ Use the `type` value in a `component_tree` node. Reach for what already exists:
|
|
|
77
181
|
- Hero/media: `CarouselWidget` (rotating hero slides w/ CTA), `ImageWidget`, `VideoWidget`
|
|
78
182
|
- Content: `TextWidget`, `BulletListWidget`, `CardWidget`, `AlertWidget`, `TableWidget`, `ChartWidget`, `CalendarWidget`
|
|
79
183
|
- Commerce/member: `ShopWidget`, `PointsWidget` (rewards balance), `RecentActivityWidget`, `ToDoWidget`
|
|
80
|
-
- Links/sharing: `LinkWidget`, `
|
|
81
|
-
- Platform: `EmbedWidget` (drops a Mist app into a screen), `MySiteWidget
|
|
184
|
+
- Links/sharing: `LinkWidget`, `QuickLinksWidget`, `QuickShareWidget`, `ListWidget`
|
|
185
|
+
- Platform: `EmbedWidget` (drops a Mist app into a screen), `MySiteWidget`
|
|
186
|
+
|
|
187
|
+
`TextWidget` renders its `title` and `description` as plain text. Do not put
|
|
188
|
+
HTML in either field; markup is escaped and displayed literally.
|
|
82
189
|
|
|
83
190
|
An unregistered `type` renders nothing. Data-driven UI a built-in can't express (a member-specific dashboard) needs a custom widget package — a separate concern from this definition-editing workflow; note it to the user rather than hand-rolling code here.
|
|
84
191
|
|
|
@@ -111,8 +218,10 @@ Expected result: the portal CLI pulls the definition when `portal/` is missing,
|
|
|
111
218
|
|
|
112
219
|
Two local-preview rules that read as bugs if you do not know them:
|
|
113
220
|
|
|
221
|
+
- `fluid portal dev` injects `portalDevPlugin()` and `backendDevPlugin()` programmatically. Do not add either plugin to `vite.config.ts`; doing so creates scaffold drift and is unnecessary. If the command falls back to bare Vite, fix the dependency/config error that triggered the fallback instead of permanently wiring the plugin.
|
|
222
|
+
- With no `VITE_API_URL` override, the CLI resolves the signed-in company and proxies `/api` to `https://<subdomain>.portal.fluid.app`. Portal member endpoints do **not** live on `api.fluid.app` or `<subdomain>.fluid.app`; repeated 404s from those hosts are a proxy-target error.
|
|
114
223
|
- The local manifest always serves the profile with `default: true` (or the first profile file if none is default). Profile `permissions` are **not** evaluated locally — edits to any other profile will never appear in the local preview. Permission matching only happens on the deployed portal against the real logged-in member.
|
|
115
|
-
-
|
|
224
|
+
- A direct localhost preview does **not** have a real portal-member session. The tenant BFF authenticates with an HttpOnly `portal_tenant_user_id` cookie created by the production/Rails handoff, not the Fluid CLI token. Local custom pages, navigation, and definition content still work, but Shop, Orders, Contacts, rep-only screens, and other member-data surfaces may return 401 or remain unauthenticated. A host fix that merely changes an error boundary into permanent skeletons is not successful authentication. Verify signed-in behavior through a real tenant handoff environment.
|
|
116
225
|
|
|
117
226
|
## Push definition changes
|
|
118
227
|
|
|
@@ -147,6 +256,11 @@ pnpm exec fluid portal version create --activate
|
|
|
147
256
|
|
|
148
257
|
Use this only when the pushed definition is ready for users. If activation should be coordinated with a release or content review, stop and ask the project owner before running it.
|
|
149
258
|
|
|
259
|
+
For an explicitly approved non-interactive release, use
|
|
260
|
+
`fluid portal push --yes --activate`. It performs the same two stages but
|
|
261
|
+
activates only after every push phase succeeds; malformed or partially-pushed
|
|
262
|
+
definitions exit non-zero and are not activated.
|
|
263
|
+
|
|
150
264
|
## Distinguish the three deploy/sync paths
|
|
151
265
|
|
|
152
266
|
Do not mix these up:
|
|
@@ -175,10 +289,12 @@ Work these in order and report which one failed:
|
|
|
175
289
|
|
|
176
290
|
1. Logged in? Auth errors say `Run fluid login first`.
|
|
177
291
|
2. Right directory? Push errors about missing `portal/` or `.portal-sync/` mean you are not in the pulled project (or never pulled).
|
|
178
|
-
3.
|
|
179
|
-
4.
|
|
180
|
-
5.
|
|
181
|
-
6.
|
|
292
|
+
3. Built-in screens returning 404? Confirm the proxy target is `<subdomain>.portal.fluid.app`, not `api.fluid.app` or `<subdomain>.fluid.app`.
|
|
293
|
+
4. Built-in screens returning 401 or permanent skeletons? Localhost lacks the portal handoff cookie; use a real tenant handoff environment for authenticated-member verification.
|
|
294
|
+
5. Local preview showing stale/missing definition content? Check for malformed JSON or broken references in the file you last edited (local preview drops them silently), and confirm you edited the profile the local preview serves (`default: true`).
|
|
295
|
+
6. Push refused? Read the cross-reference validation errors — they name the file and the missing slug.
|
|
296
|
+
7. Push partially failed? Fix the reported phase error and rerun; the snapshot only advanced for files that succeeded.
|
|
297
|
+
8. Users do not see the change? Push updates the draft only — create/activate a version.
|
|
182
298
|
|
|
183
299
|
## Widget work inside a portal project
|
|
184
300
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Fluid API host. LEAVE UNSET for local development: `fluid portal dev`
|
|
2
|
-
# forces requests same-origin and
|
|
3
|
-
# https
|
|
4
|
-
#
|
|
2
|
+
# forces requests same-origin and resolves the signed-in company's tenant BFF
|
|
3
|
+
# (`https://<subdomain>.portal.fluid.app`). Setting an absolute URL here only
|
|
4
|
+
# changes which API the dev proxy targets; it does not create an authenticated
|
|
5
|
+
# portal-member handoff session.
|
|
5
6
|
# VITE_API_URL=
|
|
6
7
|
|
|
7
8
|
# Optional: override the Fluid CLI auth token for this project.
|
|
@@ -35,7 +35,20 @@ pnpm push # fluid portal push
|
|
|
35
35
|
pnpm widget:create <name> # scaffold a company-owned portal widget
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
When `VITE_API_URL` is unset, `fluid portal dev` resolves the signed-in
|
|
39
|
+
company and proxies `/api` to its tenant BFF at
|
|
40
|
+
`https://<subdomain>.portal.fluid.app`. Set `VITE_API_URL` in `.env` only when
|
|
41
|
+
you need a different API host. The override changes routing, not
|
|
42
|
+
authentication.
|
|
43
|
+
|
|
44
|
+
Local preview always serves your local `portal/` manifest, custom pages, and
|
|
45
|
+
navigation. Built-in screens that load member data also require the
|
|
46
|
+
`portal_tenant_user_id` session cookie created by the production/Rails handoff.
|
|
47
|
+
A direct localhost preview cannot create or read that HttpOnly tenant cookie,
|
|
48
|
+
so Shop, Orders, Contacts, and other member-data screens may return HTTP 401 or
|
|
49
|
+
remain in an unauthenticated state. Do not treat persistent skeletons as proof
|
|
50
|
+
that authenticated data works; verify those screens through a real tenant
|
|
51
|
+
handoff environment.
|
|
39
52
|
|
|
40
53
|
## Project structure
|
|
41
54
|
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
import { t as __exportAll } from "./rolldown-runtime-twds-ZHy.mjs";
|
|
2
|
-
import { basename, extname, join, resolve, sep } from "node:path";
|
|
3
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
6
|
-
//#region src/vite-plugin/build-manifest.ts
|
|
7
|
-
/**
|
|
8
|
-
* Build a RawManifestResponse from local portal/ files.
|
|
9
|
-
*
|
|
10
|
-
* Reads the portal directory structure produced by `fluid portal pull` and
|
|
11
|
-
* assembles it into the same shape the SDK expects from the
|
|
12
|
-
* `/api/fluid_os/definitions/active` endpoint, so the existing
|
|
13
|
-
* `transformManifestToRepAppData()` pipeline works unchanged.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Generate a deterministic synthetic numeric ID from a string key.
|
|
17
|
-
* Uses a CRC32-like hash so the same slug always produces the same ID
|
|
18
|
-
* within a dev session, while avoiding collisions in practice.
|
|
19
|
-
*/
|
|
20
|
-
function syntheticId(key) {
|
|
21
|
-
return createHash("md5").update(key).digest().readUInt32BE(0);
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Read and parse a JSON file, returning null on failure.
|
|
25
|
-
*/
|
|
26
|
-
async function readJsonFile(filePath) {
|
|
27
|
-
try {
|
|
28
|
-
const content = await readFile(filePath, "utf-8");
|
|
29
|
-
return JSON.parse(content);
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Read all JSON files in a directory, returning an array of [slug, data] tuples.
|
|
36
|
-
* Slug is the filename without extension.
|
|
37
|
-
*/
|
|
38
|
-
async function readJsonDir(dirPath) {
|
|
39
|
-
if (!existsSync(dirPath)) return [];
|
|
40
|
-
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
41
|
-
const results = [];
|
|
42
|
-
for (const entry of entries) {
|
|
43
|
-
if (!entry.isFile() || extname(entry.name) !== ".json") continue;
|
|
44
|
-
const slug = basename(entry.name, ".json");
|
|
45
|
-
const data = await readJsonFile(join(dirPath, entry.name));
|
|
46
|
-
if (data != null) results.push([slug, data]);
|
|
47
|
-
}
|
|
48
|
-
return results;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Convert local navigation items (which reference screens by slug)
|
|
52
|
-
* back to the raw API format (which uses screen_id).
|
|
53
|
-
*/
|
|
54
|
-
function toRawNavigationItems(items, screenSlugToId) {
|
|
55
|
-
return items.map((item) => {
|
|
56
|
-
const rawItem = {
|
|
57
|
-
id: item.id,
|
|
58
|
-
label: item.label ?? "Untitled",
|
|
59
|
-
position: item.position ?? 0,
|
|
60
|
-
children: toRawNavigationItems(item.children ?? [], screenSlugToId)
|
|
61
|
-
};
|
|
62
|
-
if (item.slug != null) rawItem.slug = item.slug;
|
|
63
|
-
if (item.icon != null) rawItem.icon = item.icon;
|
|
64
|
-
if (item.source != null) rawItem.source = item.source;
|
|
65
|
-
if (item.parent_id != null) rawItem.parent_id = item.parent_id;
|
|
66
|
-
if (item.screen != null) {
|
|
67
|
-
const screenId = screenSlugToId.get(item.screen);
|
|
68
|
-
if (screenId != null) rawItem.screen_id = screenId;
|
|
69
|
-
}
|
|
70
|
-
return rawItem;
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Build a `RawManifestResponse` from local portal/ files.
|
|
75
|
-
*
|
|
76
|
-
* The returned object matches the shape from `/api/fluid_os/definitions/active`
|
|
77
|
-
* so the SDK's `transformManifestToRepAppData()` works without modification.
|
|
78
|
-
*/
|
|
79
|
-
async function buildManifestFromLocalFiles(portalDir) {
|
|
80
|
-
const definitionId = syntheticId(`definition:${(await readJsonFile(join(portalDir, "definition.json")))?.name ?? "Local Dev"}`);
|
|
81
|
-
const [screenEntries, themeEntries, navEntries, profileEntries] = await Promise.all([
|
|
82
|
-
readJsonDir(join(portalDir, "screens")),
|
|
83
|
-
readJsonDir(join(portalDir, "themes")),
|
|
84
|
-
readJsonDir(join(portalDir, "navigations")),
|
|
85
|
-
readJsonDir(join(portalDir, "profiles"))
|
|
86
|
-
]);
|
|
87
|
-
const screenSlugToId = /* @__PURE__ */ new Map();
|
|
88
|
-
for (const [slug] of screenEntries) screenSlugToId.set(slug, syntheticId(`screen:${slug}`));
|
|
89
|
-
const rawScreens = screenEntries.map(([slug, screen]) => ({
|
|
90
|
-
id: screenSlugToId.get(slug),
|
|
91
|
-
definition_id: definitionId,
|
|
92
|
-
name: screen.name,
|
|
93
|
-
slug,
|
|
94
|
-
component_tree: screen.component_tree
|
|
95
|
-
}));
|
|
96
|
-
const themeSlugToRaw = /* @__PURE__ */ new Map();
|
|
97
|
-
for (const [slug, theme] of themeEntries) themeSlugToRaw.set(slug, {
|
|
98
|
-
id: syntheticId(`theme:${slug}`),
|
|
99
|
-
name: theme.name,
|
|
100
|
-
config: theme.config,
|
|
101
|
-
active: theme.active
|
|
102
|
-
});
|
|
103
|
-
const rawThemes = Array.from(themeSlugToRaw.values());
|
|
104
|
-
const navSlugToId = /* @__PURE__ */ new Map();
|
|
105
|
-
for (const [slug] of navEntries) navSlugToId.set(slug, syntheticId(`navigation:${slug}`));
|
|
106
|
-
const rawNavigations = /* @__PURE__ */ new Map();
|
|
107
|
-
for (const [slug, nav] of navEntries) rawNavigations.set(slug, {
|
|
108
|
-
id: navSlugToId.get(slug),
|
|
109
|
-
name: nav.name,
|
|
110
|
-
definition_id: definitionId,
|
|
111
|
-
navigation_items: toRawNavigationItems(nav.navigation_items ?? [], screenSlugToId)
|
|
112
|
-
});
|
|
113
|
-
const defaultProfile = profileEntries.find(([, p]) => p.default)?.[1] ?? profileEntries[0]?.[1] ?? null;
|
|
114
|
-
let rawProfile;
|
|
115
|
-
if (defaultProfile) {
|
|
116
|
-
const navigation = defaultProfile.navigation ? rawNavigations.get(defaultProfile.navigation) : void 0;
|
|
117
|
-
const mobileNavigation = defaultProfile.mobile_navigation ? rawNavigations.get(defaultProfile.mobile_navigation) : void 0;
|
|
118
|
-
const profileThemeSet = new Set(defaultProfile.themes);
|
|
119
|
-
const profileThemes = profileThemeSet.size > 0 ? Array.from(profileThemeSet).map((slug) => themeSlugToRaw.get(slug)).filter((t) => t != null) : rawThemes;
|
|
120
|
-
rawProfile = {
|
|
121
|
-
name: defaultProfile.name,
|
|
122
|
-
definition_id: definitionId,
|
|
123
|
-
themes: profileThemes,
|
|
124
|
-
...navigation ? { navigation } : {},
|
|
125
|
-
...mobileNavigation ? { mobile_navigation: mobileNavigation } : {}
|
|
126
|
-
};
|
|
127
|
-
} else if (rawThemes.length > 0 || rawNavigations.size > 0) {
|
|
128
|
-
const firstNav = rawNavigations.size > 0 ? rawNavigations.values().next().value : void 0;
|
|
129
|
-
rawProfile = {
|
|
130
|
-
name: "Default",
|
|
131
|
-
definition_id: definitionId,
|
|
132
|
-
themes: rawThemes,
|
|
133
|
-
...firstNav ? { navigation: firstNav } : {}
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
return { manifest: {
|
|
137
|
-
definition_id: definitionId,
|
|
138
|
-
published_version: 0,
|
|
139
|
-
screens: rawScreens,
|
|
140
|
-
...rawProfile ? { profile: rawProfile } : {}
|
|
141
|
-
} };
|
|
142
|
-
}
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/vite-plugin/portal-dev-plugin.ts
|
|
145
|
-
/**
|
|
146
|
-
* Vite plugin for local portal content development.
|
|
147
|
-
*
|
|
148
|
-
* Intercepts the manifest API request and serves content assembled from the
|
|
149
|
-
* local `portal/` directory. Watches portal files for changes and triggers a
|
|
150
|
-
* full page reload.
|
|
151
|
-
*
|
|
152
|
-
* The manifest is intercepted on BOTH paths the SDK has used across versions
|
|
153
|
-
* (`/api/app/manifest` since portal-sdk 0.1.4xx, `/api/fluid_os/definitions/
|
|
154
|
-
* active` before that) so a newer SDK against an older-generation app — or
|
|
155
|
-
* vice versa — still gets the local draft.
|
|
156
|
-
*/
|
|
157
|
-
var portal_dev_plugin_exports = /* @__PURE__ */ __exportAll({ portalDevPlugin: () => portalDevPlugin });
|
|
158
|
-
const DEBOUNCE_MS = 100;
|
|
159
|
-
/** Manifest endpoints across SDK generations — all serve the local draft. */
|
|
160
|
-
const MANIFEST_PATHS = ["/api/app/manifest", "/api/fluid_os/definitions/active"];
|
|
161
|
-
const DEFAULT_API_URL = "https://api.fluid.app";
|
|
162
|
-
/**
|
|
163
|
-
* Fallback .env reader for VITE_API_URL, used only when vite's `loadEnv`
|
|
164
|
-
* can't be imported (it's a declared dependency, so this is defensive — but
|
|
165
|
-
* a broken install must degrade to "wrong host" rather than "dev server
|
|
166
|
-
* won't boot"). Mirrors the basic lookup order: live env > .env.local >
|
|
167
|
-
* .env. Unlike loadEnv it ignores .env.[mode] files and variable expansion.
|
|
168
|
-
*/
|
|
169
|
-
function readViteApiUrlFallback(root) {
|
|
170
|
-
if (process.env.VITE_API_URL) return process.env.VITE_API_URL;
|
|
171
|
-
if (process.env.NEXT_PUBLIC_API_BASE_URL) return process.env.NEXT_PUBLIC_API_BASE_URL;
|
|
172
|
-
for (const file of [".env.local", ".env"]) {
|
|
173
|
-
const path = join(root, file);
|
|
174
|
-
if (!existsSync(path)) continue;
|
|
175
|
-
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
176
|
-
const match = /^\s*VITE_API_URL\s*=\s*(?:"([^"]*)"|(.*?))\s*(?:#.*)?$/.exec(line);
|
|
177
|
-
const value = match?.[1] ?? match?.[2];
|
|
178
|
-
if (value) return value;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Resolve VITE_API_URL the way vite itself would — `loadEnv` honors
|
|
184
|
-
* .env.[mode], .env.[mode].local, envDir, and variable expansion, which the
|
|
185
|
-
* fallback parser does not (a mode-specific staging API must not silently
|
|
186
|
-
* fall back to production).
|
|
187
|
-
*/
|
|
188
|
-
async function resolveViteApiUrl(root, mode, envDir) {
|
|
189
|
-
try {
|
|
190
|
-
const { loadEnv } = await import("vite");
|
|
191
|
-
const env = loadEnv(mode, envDir ?? root, ["VITE_", "NEXT_PUBLIC_"]);
|
|
192
|
-
return env.VITE_API_URL || process.env.VITE_API_URL || env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || void 0;
|
|
193
|
-
} catch {
|
|
194
|
-
return readViteApiUrlFallback(root);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
function portalDevPlugin(options) {
|
|
198
|
-
let portalDir;
|
|
199
|
-
let cachedManifest = null;
|
|
200
|
-
let buildInFlight = null;
|
|
201
|
-
return {
|
|
202
|
-
name: "fluid-portal-dev",
|
|
203
|
-
enforce: "pre",
|
|
204
|
-
async config(config, env) {
|
|
205
|
-
if (env?.command !== "serve" || options?.sameOriginDev === false) return {};
|
|
206
|
-
const root = config.root ?? process.cwd();
|
|
207
|
-
const target = options?.apiProxyTarget ?? await resolveViteApiUrl(root, env?.mode ?? "development", config.envDir) ?? DEFAULT_API_URL;
|
|
208
|
-
const defines = {};
|
|
209
|
-
for (const key of ["import.meta.env.VITE_API_URL", "import.meta.env.NEXT_PUBLIC_API_BASE_URL"]) if (config.define?.[key] === void 0) defines[key] = JSON.stringify("");
|
|
210
|
-
return {
|
|
211
|
-
define: defines,
|
|
212
|
-
server: { proxy: config.server?.proxy?.["/api"] === void 0 ? { "/api": {
|
|
213
|
-
target,
|
|
214
|
-
changeOrigin: true
|
|
215
|
-
} } : {} }
|
|
216
|
-
};
|
|
217
|
-
},
|
|
218
|
-
configResolved(config) {
|
|
219
|
-
const root = config.root ?? process.cwd();
|
|
220
|
-
portalDir = options?.portalDir ? resolve(root, options.portalDir) : join(root, "portal");
|
|
221
|
-
},
|
|
222
|
-
configureServer(server) {
|
|
223
|
-
if (server.__fluidPortalDevWired) return;
|
|
224
|
-
server.__fluidPortalDevWired = true;
|
|
225
|
-
const watchDir = portalDir;
|
|
226
|
-
if (existsSync(watchDir)) {
|
|
227
|
-
server.watcher.add(watchDir);
|
|
228
|
-
let reloadTimer = null;
|
|
229
|
-
const onFileChange = (filePath) => {
|
|
230
|
-
if (!filePath.startsWith(watchDir)) return;
|
|
231
|
-
cachedManifest = null;
|
|
232
|
-
buildInFlight = null;
|
|
233
|
-
console.log(`[fluid-portal-dev] File changed: ${filePath.replace(watchDir + sep, "")}`);
|
|
234
|
-
if (reloadTimer) clearTimeout(reloadTimer);
|
|
235
|
-
reloadTimer = setTimeout(() => {
|
|
236
|
-
reloadTimer = null;
|
|
237
|
-
server.ws.send({
|
|
238
|
-
type: "full-reload",
|
|
239
|
-
path: "*"
|
|
240
|
-
});
|
|
241
|
-
}, DEBOUNCE_MS);
|
|
242
|
-
};
|
|
243
|
-
server.watcher.on("change", onFileChange);
|
|
244
|
-
server.watcher.on("add", onFileChange);
|
|
245
|
-
server.watcher.on("unlink", onFileChange);
|
|
246
|
-
}
|
|
247
|
-
server.middlewares.use((req, res, next) => {
|
|
248
|
-
const url = req.url;
|
|
249
|
-
if (!url || !MANIFEST_PATHS.some((path) => url.includes(path))) {
|
|
250
|
-
next();
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
(async () => {
|
|
254
|
-
try {
|
|
255
|
-
if (!existsSync(portalDir)) {
|
|
256
|
-
next();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (cachedManifest == null) {
|
|
260
|
-
if (!buildInFlight) buildInFlight = buildManifestFromLocalFiles(portalDir).then((m) => {
|
|
261
|
-
cachedManifest = JSON.stringify(m);
|
|
262
|
-
buildInFlight = null;
|
|
263
|
-
return cachedManifest;
|
|
264
|
-
}).catch((err) => {
|
|
265
|
-
buildInFlight = null;
|
|
266
|
-
throw err;
|
|
267
|
-
});
|
|
268
|
-
cachedManifest = await buildInFlight;
|
|
269
|
-
}
|
|
270
|
-
res.writeHead(200, {
|
|
271
|
-
"Content-Type": "application/json",
|
|
272
|
-
"X-Portal-Dev-Mode": "true",
|
|
273
|
-
"Cache-Control": "no-store"
|
|
274
|
-
});
|
|
275
|
-
res.end(cachedManifest);
|
|
276
|
-
} catch (err) {
|
|
277
|
-
console.error("[fluid-portal-dev] Error building manifest:", err);
|
|
278
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
279
|
-
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Failed to build manifest from local files" }));
|
|
280
|
-
}
|
|
281
|
-
})();
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
//#endregion
|
|
287
|
-
export { portal_dev_plugin_exports as n, buildManifestFromLocalFiles as r, portalDevPlugin as t };
|
|
288
|
-
|
|
289
|
-
//# sourceMappingURL=portal-dev-plugin-BHE8Z4yr.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"portal-dev-plugin-BHE8Z4yr.mjs","names":[],"sources":["../src/vite-plugin/build-manifest.ts","../src/vite-plugin/portal-dev-plugin.ts"],"sourcesContent":["/**\n * Build a RawManifestResponse from local portal/ files.\n *\n * Reads the portal directory structure produced by `fluid portal pull` and\n * assembles it into the same shape the SDK expects from the\n * `/api/fluid_os/definitions/active` endpoint, so the existing\n * `transformManifestToRepAppData()` pipeline works unchanged.\n */\n\nimport { readFile, readdir } from \"node:fs/promises\";\nimport { join, basename, extname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\n\nimport type {\n LocalScreen,\n LocalTheme,\n LocalNavigation,\n LocalNavigationItem,\n LocalProfile,\n} from \"../utils/transform.js\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Types matching the RawManifestResponse shape from\n// packages/portal/sdk/src/transforms/index.ts\n// ─────────────────────────────────────────────────────────────────────────────\n\ninterface RawNavigationItem {\n id: number;\n label: string;\n slug?: string;\n icon?: string;\n screen_id?: number;\n parent_id?: number;\n source?: string;\n position: number;\n children: RawNavigationItem[];\n}\n\ninterface RawNavigation {\n id: number;\n name?: string;\n definition_id: number;\n navigation_items?: RawNavigationItem[];\n}\n\ninterface RawTheme {\n id: number;\n config?: Record<string, unknown> | null;\n active?: boolean | null;\n name?: string | null;\n}\n\ninterface RawScreen {\n id: number;\n definition_id?: number;\n name?: string | null;\n slug?: string | null;\n component_tree?: unknown;\n}\n\ninterface RawManifestProfile {\n name?: string;\n definition_id: number;\n themes?: RawTheme[];\n navigation?: RawNavigation;\n mobile_navigation?: RawNavigation;\n}\n\ninterface RawManifestResponse {\n manifest: {\n definition_id: number;\n published_version?: number;\n screens?: RawScreen[];\n profile?: RawManifestProfile;\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Synthetic ID generation\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Generate a deterministic synthetic numeric ID from a string key.\n * Uses a CRC32-like hash so the same slug always produces the same ID\n * within a dev session, while avoiding collisions in practice.\n */\nfunction syntheticId(key: string): number {\n const hash = createHash(\"md5\").update(key).digest();\n // Read first 4 bytes as unsigned 32-bit integer, ensure positive\n return hash.readUInt32BE(0);\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// File reading helpers\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Read and parse a JSON file, returning null on failure.\n */\nasync function readJsonFile<T>(filePath: string): Promise<T | null> {\n try {\n const content = await readFile(filePath, \"utf-8\");\n return JSON.parse(content) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Read all JSON files in a directory, returning an array of [slug, data] tuples.\n * Slug is the filename without extension.\n */\nasync function readJsonDir<T>(\n dirPath: string,\n): Promise<Array<[slug: string, data: T]>> {\n if (!existsSync(dirPath)) return [];\n\n const entries = await readdir(dirPath, { withFileTypes: true });\n const results: Array<[string, T]> = [];\n\n for (const entry of entries) {\n if (!entry.isFile() || extname(entry.name) !== \".json\") continue;\n\n const slug = basename(entry.name, \".json\");\n const data = await readJsonFile<T>(join(dirPath, entry.name));\n if (data != null) {\n results.push([slug, data]);\n }\n }\n\n return results;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Navigation item conversion\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Convert local navigation items (which reference screens by slug)\n * back to the raw API format (which uses screen_id).\n */\nfunction toRawNavigationItems(\n items: LocalNavigationItem[],\n screenSlugToId: Map<string, number>,\n): RawNavigationItem[] {\n return items.map((item) => {\n const rawItem: RawNavigationItem = {\n id: item.id,\n label: item.label ?? \"Untitled\",\n position: item.position ?? 0,\n children: toRawNavigationItems(item.children ?? [], screenSlugToId),\n };\n\n if (item.slug != null) rawItem.slug = item.slug;\n if (item.icon != null) rawItem.icon = item.icon;\n if (item.source != null) rawItem.source = item.source;\n if (item.parent_id != null) rawItem.parent_id = item.parent_id;\n\n // Resolve screen slug -> synthetic screen ID\n if (item.screen != null) {\n const screenId = screenSlugToId.get(item.screen);\n if (screenId != null) {\n rawItem.screen_id = screenId;\n }\n }\n\n return rawItem;\n });\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Main builder\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Build a `RawManifestResponse` from local portal/ files.\n *\n * The returned object matches the shape from `/api/fluid_os/definitions/active`\n * so the SDK's `transformManifestToRepAppData()` works without modification.\n */\nexport async function buildManifestFromLocalFiles(\n portalDir: string,\n): Promise<RawManifestResponse> {\n // 1. Read definition.json\n const definition = await readJsonFile<{ name?: string }>(\n join(portalDir, \"definition.json\"),\n );\n const definitionName = definition?.name ?? \"Local Dev\";\n const definitionId = syntheticId(`definition:${definitionName}`);\n\n // 2. Read all resource files in parallel\n const [screenEntries, themeEntries, navEntries, profileEntries] =\n await Promise.all([\n readJsonDir<LocalScreen>(join(portalDir, \"screens\")),\n readJsonDir<LocalTheme>(join(portalDir, \"themes\")),\n readJsonDir<LocalNavigation>(join(portalDir, \"navigations\")),\n readJsonDir<LocalProfile>(join(portalDir, \"profiles\")),\n ]);\n\n // 3. Build screen slug -> synthetic ID map\n const screenSlugToId = new Map<string, number>();\n for (const [slug] of screenEntries) {\n screenSlugToId.set(slug, syntheticId(`screen:${slug}`));\n }\n\n // 4. Build raw screens\n const rawScreens: RawScreen[] = screenEntries.map(([slug, screen]) => ({\n id: screenSlugToId.get(slug)!,\n definition_id: definitionId,\n name: screen.name,\n slug,\n component_tree: screen.component_tree,\n }));\n\n // 5. Build raw themes (keyed by slug for profile lookup)\n const themeSlugToRaw = new Map<string, RawTheme>();\n for (const [slug, theme] of themeEntries) {\n themeSlugToRaw.set(slug, {\n id: syntheticId(`theme:${slug}`),\n name: theme.name,\n config: theme.config,\n active: theme.active,\n });\n }\n const rawThemes = Array.from(themeSlugToRaw.values());\n\n // 6. Build navigation slug -> synthetic ID map\n const navSlugToId = new Map<string, number>();\n for (const [slug] of navEntries) {\n navSlugToId.set(slug, syntheticId(`navigation:${slug}`));\n }\n\n // Build raw navigations (keyed by slug for profile lookup)\n const rawNavigations = new Map<string, RawNavigation>();\n for (const [slug, nav] of navEntries) {\n rawNavigations.set(slug, {\n id: navSlugToId.get(slug)!,\n name: nav.name,\n definition_id: definitionId,\n navigation_items: toRawNavigationItems(\n nav.navigation_items ?? [],\n screenSlugToId,\n ),\n });\n }\n\n // 7. Find the default profile (or use the first one)\n const defaultProfile =\n profileEntries.find(([, p]) => p.default)?.[1] ??\n profileEntries[0]?.[1] ??\n null;\n\n // 8. Assemble the profile for the manifest\n let rawProfile: RawManifestProfile | undefined;\n if (defaultProfile) {\n // Resolve navigation slugs to raw navigation objects\n const navigation = defaultProfile.navigation\n ? rawNavigations.get(defaultProfile.navigation)\n : undefined;\n\n const mobileNavigation = defaultProfile.mobile_navigation\n ? rawNavigations.get(defaultProfile.mobile_navigation)\n : undefined;\n\n // Filter themes to those referenced by the profile (by slug)\n const profileThemeSet = new Set(defaultProfile.themes);\n const profileThemes =\n profileThemeSet.size > 0\n ? Array.from(profileThemeSet)\n .map((slug) => themeSlugToRaw.get(slug))\n .filter((t): t is RawTheme => t != null)\n : rawThemes; // If no themes specified in profile, include all\n\n rawProfile = {\n name: defaultProfile.name,\n definition_id: definitionId,\n themes: profileThemes,\n ...(navigation ? { navigation } : {}),\n ...(mobileNavigation ? { mobile_navigation: mobileNavigation } : {}),\n };\n } else if (rawThemes.length > 0 || rawNavigations.size > 0) {\n // No profile files but we have themes/navs — create a synthetic profile\n const firstNav =\n rawNavigations.size > 0\n ? rawNavigations.values().next().value\n : undefined;\n\n rawProfile = {\n name: \"Default\",\n definition_id: definitionId,\n themes: rawThemes,\n ...(firstNav ? { navigation: firstNav } : {}),\n };\n }\n\n // 9. Return assembled manifest\n return {\n manifest: {\n definition_id: definitionId,\n published_version: 0,\n screens: rawScreens,\n ...(rawProfile ? { profile: rawProfile } : {}),\n },\n };\n}\n","/**\n * Vite plugin for local portal content development.\n *\n * Intercepts the manifest API request and serves content assembled from the\n * local `portal/` directory. Watches portal files for changes and triggers a\n * full page reload.\n *\n * The manifest is intercepted on BOTH paths the SDK has used across versions\n * (`/api/app/manifest` since portal-sdk 0.1.4xx, `/api/fluid_os/definitions/\n * active` before that) so a newer SDK against an older-generation app — or\n * vice versa — still gets the local draft.\n */\n\nimport { join, resolve, sep } from \"node:path\";\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { buildManifestFromLocalFiles } from \"./build-manifest.js\";\n\n/** Options for the portal dev plugin. */\nexport interface PortalDevPluginOptions {\n portalDir?: string;\n /**\n * When true (default), dev-server requests are forced same-origin: the\n * plugin blanks `import.meta.env.VITE_API_URL` in serve mode and proxies\n * `/api` to the configured Fluid host instead. Without this, a `.env`\n * pointing VITE_API_URL at a remote API makes the SPA fetch its manifest\n * cross-origin — silently bypassing this plugin and rendering the remote\n * app instead of the local `portal/` draft. Set false to opt out and let\n * the SPA call the remote API directly.\n */\n sameOriginDev?: boolean;\n /** Override the `/api` dev-proxy target. Defaults to VITE_API_URL from the process env / .env files, then https://api.fluid.app. */\n apiProxyTarget?: string;\n}\n\nconst DEBOUNCE_MS = 100;\n\n/** Manifest endpoints across SDK generations — all serve the local draft. */\nconst MANIFEST_PATHS = [\n \"/api/app/manifest\",\n \"/api/fluid_os/definitions/active\",\n];\n\nconst DEFAULT_API_URL = \"https://api.fluid.app\";\n\n/**\n * Fallback .env reader for VITE_API_URL, used only when vite's `loadEnv`\n * can't be imported (it's a declared dependency, so this is defensive — but\n * a broken install must degrade to \"wrong host\" rather than \"dev server\n * won't boot\"). Mirrors the basic lookup order: live env > .env.local >\n * .env. Unlike loadEnv it ignores .env.[mode] files and variable expansion.\n */\nfunction readViteApiUrlFallback(root: string): string | undefined {\n if (process.env.VITE_API_URL) return process.env.VITE_API_URL;\n if (process.env.NEXT_PUBLIC_API_BASE_URL) {\n return process.env.NEXT_PUBLIC_API_BASE_URL;\n }\n for (const file of [\".env.local\", \".env\"]) {\n const path = join(root, file);\n if (!existsSync(path)) continue;\n for (const line of readFileSync(path, \"utf8\").split(\"\\n\")) {\n // Quoted values stop at the closing `\"`; unquoted values stop before\n // an inline ` # comment` tail (dotenv semantics — a trailing comment\n // must not leak into the proxy target URL).\n const match =\n /^\\s*VITE_API_URL\\s*=\\s*(?:\"([^\"]*)\"|(.*?))\\s*(?:#.*)?$/.exec(line);\n const value = match?.[1] ?? match?.[2];\n if (value) return value;\n }\n }\n return undefined;\n}\n\n/**\n * Resolve VITE_API_URL the way vite itself would — `loadEnv` honors\n * .env.[mode], .env.[mode].local, envDir, and variable expansion, which the\n * fallback parser does not (a mode-specific staging API must not silently\n * fall back to production).\n */\nasync function resolveViteApiUrl(\n root: string,\n mode: string,\n envDir: string | undefined,\n): Promise<string | undefined> {\n try {\n const { loadEnv } = await import(\"vite\");\n const env = loadEnv(mode, envDir ?? root, [\"VITE_\", \"NEXT_PUBLIC_\"]);\n return (\n env.VITE_API_URL ||\n process.env.VITE_API_URL ||\n // Portals migrated from Next.js conventions configure the API host as\n // NEXT_PUBLIC_API_BASE_URL — honored by `fluid portal dev` since it\n // was introduced, so it stays a recognized source here.\n env.NEXT_PUBLIC_API_BASE_URL ||\n process.env.NEXT_PUBLIC_API_BASE_URL ||\n undefined\n );\n } catch {\n return readViteApiUrlFallback(root);\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function portalDevPlugin(options?: PortalDevPluginOptions): any {\n let portalDir: string;\n let cachedManifest: string | null = null;\n let buildInFlight: Promise<string> | null = null;\n\n return {\n name: \"fluid-portal-dev\",\n enforce: \"pre\" as const,\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n async config(config: any, env: any) {\n if (env?.command !== \"serve\" || options?.sameOriginDev === false) {\n return {};\n }\n\n const root: string = config.root ?? process.cwd();\n const target =\n options?.apiProxyTarget ??\n (await resolveViteApiUrl(\n root,\n env?.mode ?? \"development\",\n config.envDir,\n )) ??\n DEFAULT_API_URL;\n\n // Vite merges a plugin's returned config OVER the user config, so an\n // app's own explicit define / /api proxy is only preserved by checking\n // for it and yielding here. BOTH API-host env vars must be blanked:\n // dev.ts's envPrefix exposes NEXT_PUBLIC_API_BASE_URL to the SPA too,\n // so leaving it defined would recreate the exact cross-origin bypass\n // this hook exists to prevent, just for Next.js-migrated portals.\n const defines: Record<string, string> = {};\n for (const key of [\n \"import.meta.env.VITE_API_URL\",\n \"import.meta.env.NEXT_PUBLIC_API_BASE_URL\",\n ]) {\n if (config.define?.[key] === undefined) {\n defines[key] = JSON.stringify(\"\");\n }\n }\n const proxy =\n config.server?.proxy?.[\"/api\"] === undefined\n ? { \"/api\": { target, changeOrigin: true } }\n : {};\n\n return {\n define: defines,\n server: { proxy },\n };\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configResolved(config: any) {\n const root: string = config.root ?? process.cwd();\n portalDir = options?.portalDir\n ? resolve(root, options.portalDir)\n : join(root, \"portal\");\n },\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n configureServer(server: any) {\n // Idempotency: `fluid portal dev` injects this plugin, and a portal's\n // own vite.config may also wire it. Only the first instance registers\n // the watcher + middleware so we don't double-reload or double-serve.\n if (server.__fluidPortalDevWired) return;\n server.__fluidPortalDevWired = true;\n\n const watchDir = portalDir;\n\n if (existsSync(watchDir)) {\n server.watcher.add(watchDir);\n\n let reloadTimer: ReturnType<typeof setTimeout> | null = null;\n\n const onFileChange = (filePath: string) => {\n if (!filePath.startsWith(watchDir)) return;\n\n cachedManifest = null;\n buildInFlight = null;\n\n console.log(\n `[fluid-portal-dev] File changed: ${filePath.replace(watchDir + sep, \"\")}`,\n );\n\n if (reloadTimer) clearTimeout(reloadTimer);\n reloadTimer = setTimeout(() => {\n reloadTimer = null;\n server.ws.send({ type: \"full-reload\", path: \"*\" });\n }, DEBOUNCE_MS);\n };\n\n server.watcher.on(\"change\", onFileChange);\n server.watcher.on(\"add\", onFileChange);\n server.watcher.on(\"unlink\", onFileChange);\n }\n\n // ── Middleware to intercept manifest requests ───────────────────\n // Every MANIFEST_PATHS route returns the SAME `{ manifest }` payload:\n // the portal-tenant BFF route the current SDK runtime fetches\n // (useFluidApp → app_manifest_show) and the legacy FluidOS route, so\n // the local portal/ draft feeds the running app regardless of which\n // runtime generation it uses (the SDK adapter reads\n // `response.manifest`, matching buildManifestFromLocalFiles' output).\n server.middlewares.use(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (req: any, res: any, next: () => void) => {\n const url: string | undefined = req.url;\n\n if (!url || !MANIFEST_PATHS.some((path) => url.includes(path))) {\n next();\n return;\n }\n\n (async () => {\n try {\n if (!existsSync(portalDir)) {\n // Fall through to the Vite proxy so requests reach the real\n // API when local portal content is unavailable (e.g. --skip-pull).\n next();\n return;\n }\n\n if (cachedManifest == null) {\n if (!buildInFlight) {\n buildInFlight = buildManifestFromLocalFiles(portalDir)\n .then((m) => {\n cachedManifest = JSON.stringify(m);\n buildInFlight = null;\n return cachedManifest;\n })\n .catch((err) => {\n buildInFlight = null; // allow retry on next request\n throw err;\n });\n }\n cachedManifest = await buildInFlight;\n }\n\n res.writeHead(200, {\n \"Content-Type\": \"application/json\",\n \"X-Portal-Dev-Mode\": \"true\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(cachedManifest);\n } catch (err) {\n console.error(\"[fluid-portal-dev] Error building manifest:\", err);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error:\n err instanceof Error\n ? err.message\n : \"Failed to build manifest from local files\",\n }),\n );\n }\n })();\n },\n );\n },\n };\n}\n"],"mappings":"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"}
|