@plitzi/sdk-server 0.37.9 → 0.38.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/CHANGELOG.md +615 -0
- package/README.md +17 -0
- package/dist/adapters/cloudAdapters.d.ts +5 -3
- package/dist/adapters/cloudAdapters.js +120 -34
- package/dist/adapters/space/types.d.ts +3 -0
- package/dist/core/configSeam.d.ts +14 -0
- package/dist/core/configSeam.js +114 -0
- package/dist/core/configSeam.test.d.ts +1 -0
- package/dist/core/createServer.js +11 -5
- package/dist/core/createServer.publicData.test.d.ts +1 -0
- package/dist/core/freePort.d.ts +10 -0
- package/dist/core/freePort.js +36 -0
- package/dist/core/freePort.test.d.ts +1 -0
- package/dist/core/http/stages/images.d.ts +6 -0
- package/dist/core/http/stages/images.js +47 -0
- package/dist/core/http/stages/images.test.d.ts +1 -0
- package/dist/core/server/drain.js +9 -4
- package/dist/core/server/fleet/channel.js +1 -1
- package/dist/core/server/fleet/relay.js +1 -1
- package/dist/core/services/action.js +5 -4
- package/dist/core/services/registry.js +2 -0
- package/dist/helpers/cache/keys.d.ts +23 -10
- package/dist/helpers/cache/keys.js +16 -2
- package/dist/helpers/flagOverrides.d.ts +27 -0
- package/dist/helpers/flagOverrides.js +25 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -1
- package/dist/modules/actions/moduleFor.js +5 -3
- package/dist/modules/actions/runtime/flags.d.ts +16 -0
- package/dist/modules/actions/runtime/flags.js +49 -0
- package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
- package/dist/modules/actions/runtime/kvList.js +1 -1
- package/dist/modules/actions/runtime/runAction.js +2 -0
- package/dist/modules/actions/transport/callHandler.js +8 -0
- package/dist/modules/actions/types.d.ts +14 -1
- package/dist/modules/connectors/engine.js +1 -1
- package/dist/modules/functions/capabilities.js +1 -1
- package/dist/modules/functions/load.js +2 -2
- package/dist/modules/functions/manifest.js +1 -1
- package/dist/modules/functions/runner/messages.js +1 -1
- package/dist/modules/images/allowlist.d.ts +5 -0
- package/dist/modules/images/allowlist.js +18 -0
- package/dist/modules/images/imageProxy.d.ts +36 -0
- package/dist/modules/images/imageProxy.js +273 -0
- package/dist/modules/images/imageProxy.test.d.ts +1 -0
- package/dist/modules/images/transform.d.ts +10 -0
- package/dist/modules/images/transform.js +28 -0
- package/dist/modules/images/transform.test.d.ts +1 -0
- package/dist/modules/rsc/connectorRscData.d.ts +12 -7
- package/dist/modules/rsc/connectorRscData.js +12 -8
- package/dist/modules/rsc/handler.js +18 -4
- package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
- package/dist/modules/rsc/publicFileResolver.js +39 -0
- package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
- package/dist/modules/rsc/resolveRscData.d.ts +4 -2
- package/dist/modules/rsc/resolveRscData.js +10 -2
- package/dist/modules/runtime/bundle.js +2 -2
- package/dist/modules/ssr/Component.d.ts +5 -1
- package/dist/modules/ssr/Component.js +3 -1
- package/dist/modules/ssr/prepareRender.js +36 -8
- package/package.json +19 -14
- package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
- package/skills/plitzi-authoring/SKILL.md +71 -71
- package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
- package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
- package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
- package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
- package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
- package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
- package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
- package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
- package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
- package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
- package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
- package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
- package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
- package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
- package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
- package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
- package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
- package/skills/plitzi-authoring/reference/accessibility.md +2 -2
- package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
- package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
- package/skills/plitzi-authoring/reference/components.md +9 -1
- package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
- package/skills/plitzi-authoring/reference/efficiency.md +53 -0
- package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
- package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
- package/skills/plitzi-authoring/reference/flows.md +25 -36
- package/skills/plitzi-authoring/reference/kept-state.md +28 -0
- package/skills/plitzi-authoring/reference/layouts.md +20 -3
- package/skills/plitzi-authoring/reference/lists.md +45 -16
- package/skills/plitzi-authoring/reference/plugins.md +15 -0
- package/skills/plitzi-authoring/reference/realtime.md +35 -0
- package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
- package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
- package/skills/plitzi-authoring/reference/structure.md +29 -20
- package/skills/plitzi-authoring/reference/tailwind.md +19 -0
- package/skills/plitzi-authoring/reference/templates.md +6 -5
- package/skills/plitzi-authoring/reference/testing.md +11 -0
- package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
- package/skills/plitzi-authoring/reference/validation.md +6 -2
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { fetchOutbound } from "../../helpers/outboundGuard.js";
|
|
2
|
+
import { writeFileAtomic } from "../../helpers/atomicFile.js";
|
|
3
|
+
import { isAllowedImageHost } from "./allowlist.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import { IMAGE_WIDTHS, isImageWidth } from "@plitzi/sdk-shared/helpers/images";
|
|
8
|
+
//#region src/modules/images/imageProxy.ts
|
|
9
|
+
/** The formats a picture may arrive in, as the file extension it is kept under. SVG is not one: served from this
|
|
10
|
+
* origin, an SVG is a document that can run script. */
|
|
11
|
+
var SOURCE_TYPES = {
|
|
12
|
+
"image/jpeg": "jpg",
|
|
13
|
+
"image/png": "png",
|
|
14
|
+
"image/gif": "gif",
|
|
15
|
+
"image/webp": "webp",
|
|
16
|
+
"image/avif": "avif"
|
|
17
|
+
};
|
|
18
|
+
var TYPE_OF_EXTENSION = Object.fromEntries(Object.entries(SOURCE_TYPES).map(([type, extension]) => [extension, type]));
|
|
19
|
+
/** The largest picture fetched, and how long fetching it may take. */
|
|
20
|
+
var MAX_SOURCE_BYTES = 15728640;
|
|
21
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
22
|
+
/**
|
|
23
|
+
* How long a kept original answers without asking the other site again. Past it the kept files still answer — at once —
|
|
24
|
+
* while the original is asked for with its `ETag` / `Last-Modified`: unchanged, nothing is fetched or resized again.
|
|
25
|
+
*/
|
|
26
|
+
var FRESH_MS = 6048e5;
|
|
27
|
+
/** How long a picture that could not be fetched is not asked for again: a broken address is not a reason to keep asking. */
|
|
28
|
+
var FAILED_MS = 3e5;
|
|
29
|
+
var CACHE_CONTROL = "public, max-age=86400, stale-while-revalidate=604800";
|
|
30
|
+
var HostNotAllowed = class extends Error {};
|
|
31
|
+
var answer = (status, message) => ({
|
|
32
|
+
status,
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
35
|
+
"Cache-Control": "no-store"
|
|
36
|
+
},
|
|
37
|
+
body: message
|
|
38
|
+
});
|
|
39
|
+
/** What a request is answered with when it gets no picture, carried up from wherever it was decided. */
|
|
40
|
+
var Refusal = class extends Error {
|
|
41
|
+
answer;
|
|
42
|
+
constructor(answer) {
|
|
43
|
+
super(String(answer.body));
|
|
44
|
+
this.answer = answer;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var refuse = (status, message) => new Refusal(answer(status, message));
|
|
48
|
+
/** One of each at a time: a second request for what is being made waits for the first. */
|
|
49
|
+
var oneAtATime = () => {
|
|
50
|
+
const running = /* @__PURE__ */ new Map();
|
|
51
|
+
return (key, make) => {
|
|
52
|
+
const known = running.get(key);
|
|
53
|
+
if (known) return known;
|
|
54
|
+
const made = make().finally(() => running.delete(key));
|
|
55
|
+
running.set(key, made);
|
|
56
|
+
return made;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** What the browser takes, best first; `original` when it takes neither modern format. */
|
|
60
|
+
var negotiate = (accept) => {
|
|
61
|
+
if (accept.includes("image/avif")) return "avif";
|
|
62
|
+
return accept.includes("image/webp") ? "webp" : "original";
|
|
63
|
+
};
|
|
64
|
+
var FORMATS = [
|
|
65
|
+
"avif",
|
|
66
|
+
"webp",
|
|
67
|
+
"original"
|
|
68
|
+
];
|
|
69
|
+
var isOriginalMeta = (value) => typeof value === "object" && value !== null && "type" in value && typeof value.type === "string" && "checkedAt" in value && typeof value.checkedAt === "number" && "version" in value && typeof value.version === "string";
|
|
70
|
+
var sha = (text) => createHash("sha256").update(text).digest("hex");
|
|
71
|
+
/**
|
|
72
|
+
* Another site's picture, at a width this server makes, in the best format the browser takes.
|
|
73
|
+
*
|
|
74
|
+
* Each original is fetched once and kept, and each size of it is made once from the kept original and kept too: a page
|
|
75
|
+
* asking for eight widths in two formats costs one download and sixteen resizes, ever — not one of each per request,
|
|
76
|
+
* nor again a week later. Past a week the kept files still answer at once while the other site is asked, with the
|
|
77
|
+
* original's `ETag` / `Last-Modified`, whether it changed; only a changed original makes its sizes again.
|
|
78
|
+
*
|
|
79
|
+
* Only from the hosts the deployment allows, with every hop (redirects included) held to the allowlist and to the
|
|
80
|
+
* rule every outbound request is: a picture endpoint that fetched anything would be a way to make this server read
|
|
81
|
+
* its own network.
|
|
82
|
+
*/
|
|
83
|
+
var createImageProxy = (options) => {
|
|
84
|
+
const { domains, cacheDir, transform, lookup, now = Date.now } = options;
|
|
85
|
+
const failedUntil = /* @__PURE__ */ new Map();
|
|
86
|
+
const revalidating = oneAtATime();
|
|
87
|
+
const fetchingOriginal = oneAtATime();
|
|
88
|
+
const resizing = oneAtATime();
|
|
89
|
+
const allowedFetch = (input, init) => {
|
|
90
|
+
const target = new URL(input instanceof Request ? input.url : String(input));
|
|
91
|
+
if (!isAllowedImageHost(domains, target.hostname)) return Promise.reject(new HostNotAllowed(target.hostname));
|
|
92
|
+
return options.fetch(input, init);
|
|
93
|
+
};
|
|
94
|
+
const originalFile = (urlKey, extension) => path.join(cacheDir, "originals", urlKey.slice(0, 2), `${urlKey}.${extension}`);
|
|
95
|
+
const variantKey = (urlKey, version, width, format) => sha(`${urlKey}\n${version}\n${String(width)}\n${format}`);
|
|
96
|
+
const variantFile = (key, extension) => path.join(cacheDir, key.slice(0, 2), `${key}.${extension}`);
|
|
97
|
+
const write = async (file, data) => {
|
|
98
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
99
|
+
await writeFileAtomic(file, data);
|
|
100
|
+
};
|
|
101
|
+
const readMeta = async (urlKey) => {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(await fs.readFile(originalFile(urlKey, "json"), "utf-8"));
|
|
104
|
+
return isOriginalMeta(parsed) ? parsed : void 0;
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const readFile = async (file) => {
|
|
110
|
+
try {
|
|
111
|
+
return await fs.readFile(file);
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/** A size already made, in whichever type it came out as. */
|
|
117
|
+
const keptVariant = async (key) => {
|
|
118
|
+
for (const [extension, type] of Object.entries(TYPE_OF_EXTENSION)) {
|
|
119
|
+
const body = await readFile(variantFile(key, extension));
|
|
120
|
+
if (body) return {
|
|
121
|
+
body,
|
|
122
|
+
type
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
/** The original from the other site — asked conditionally when one is kept. Throws an answer to give back. */
|
|
127
|
+
const fetchOriginal = async (source, kept) => {
|
|
128
|
+
const headers = { accept: "image/avif,image/webp,image/*" };
|
|
129
|
+
if (kept?.etag) headers["if-none-match"] = kept.etag;
|
|
130
|
+
if (kept?.lastModified) headers["if-modified-since"] = kept.lastModified;
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetchOutbound(allowedFetch, source, {
|
|
134
|
+
headers,
|
|
135
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
136
|
+
}, lookup);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw error instanceof HostNotAllowed ? refuse(403, `The picture moved to ${error.message}, which is not an allowed host.`) : refuse(502, "The picture could not be fetched.");
|
|
139
|
+
}
|
|
140
|
+
if (kept && response.status === 304) {
|
|
141
|
+
await response.body?.cancel();
|
|
142
|
+
return { changed: false };
|
|
143
|
+
}
|
|
144
|
+
const type = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
145
|
+
if (!response.ok || !Object.hasOwn(SOURCE_TYPES, type)) {
|
|
146
|
+
await response.body?.cancel();
|
|
147
|
+
throw refuse(502, response.ok ? `Not a picture this endpoint serves (${type || "no type"}).` : "The picture could not be fetched.");
|
|
148
|
+
}
|
|
149
|
+
if (Number(response.headers.get("content-length") ?? 0) > MAX_SOURCE_BYTES) {
|
|
150
|
+
await response.body?.cancel();
|
|
151
|
+
throw refuse(413, "The picture is too large.");
|
|
152
|
+
}
|
|
153
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
154
|
+
if (body.length > MAX_SOURCE_BYTES) throw refuse(413, "The picture is too large.");
|
|
155
|
+
const etag = response.headers.get("etag");
|
|
156
|
+
const lastModified = response.headers.get("last-modified");
|
|
157
|
+
return {
|
|
158
|
+
changed: true,
|
|
159
|
+
body,
|
|
160
|
+
meta: {
|
|
161
|
+
type,
|
|
162
|
+
...etag ? { etag } : {},
|
|
163
|
+
...lastModified ? { lastModified } : {},
|
|
164
|
+
checkedAt: now(),
|
|
165
|
+
version: sha(body).slice(0, 16)
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
/** Keeps a new original and its meta, and drops the sizes made from the one it replaces. */
|
|
170
|
+
const keepOriginal = async (urlKey, fetched, previous) => {
|
|
171
|
+
await write(originalFile(urlKey, SOURCE_TYPES[fetched.meta.type]), fetched.body);
|
|
172
|
+
await write(originalFile(urlKey, "json"), JSON.stringify(fetched.meta));
|
|
173
|
+
if (!previous || previous.version === fetched.meta.version) return;
|
|
174
|
+
if (previous.type !== fetched.meta.type) await fs.rm(originalFile(urlKey, SOURCE_TYPES[previous.type]), { force: true });
|
|
175
|
+
const stale = IMAGE_WIDTHS.flatMap((width) => FORMATS.flatMap((format) => {
|
|
176
|
+
const key = variantKey(urlKey, previous.version, width, format);
|
|
177
|
+
return Object.keys(TYPE_OF_EXTENSION).map((extension) => variantFile(key, extension));
|
|
178
|
+
}));
|
|
179
|
+
await Promise.all(stale.map((file) => fs.rm(file, { force: true })));
|
|
180
|
+
};
|
|
181
|
+
/** Asks the other site whether a kept original changed: unchanged costs a header, changed makes new sizes later. */
|
|
182
|
+
const revalidate = (urlKey, source, meta) => revalidating(urlKey, async () => {
|
|
183
|
+
try {
|
|
184
|
+
const fetched = await fetchOriginal(source, meta);
|
|
185
|
+
if (fetched.changed) await keepOriginal(urlKey, fetched, meta);
|
|
186
|
+
else await write(originalFile(urlKey, "json"), JSON.stringify({
|
|
187
|
+
...meta,
|
|
188
|
+
checkedAt: now()
|
|
189
|
+
}));
|
|
190
|
+
} catch {}
|
|
191
|
+
});
|
|
192
|
+
/** The original, kept or fetched: its bytes and its meta. Throws an answer to give back. */
|
|
193
|
+
const original = async (urlKey, source) => {
|
|
194
|
+
const meta = await readMeta(urlKey);
|
|
195
|
+
const body = meta && await readFile(originalFile(urlKey, SOURCE_TYPES[meta.type]));
|
|
196
|
+
if (meta && body) return {
|
|
197
|
+
body,
|
|
198
|
+
meta
|
|
199
|
+
};
|
|
200
|
+
if ((failedUntil.get(urlKey) ?? 0) > now()) throw refuse(502, "The picture could not be fetched a moment ago; it is asked for again in a few minutes.");
|
|
201
|
+
return fetchingOriginal(urlKey, async () => {
|
|
202
|
+
try {
|
|
203
|
+
const fetched = await fetchOriginal(source);
|
|
204
|
+
if (!fetched.changed) throw refuse(502, "The picture could not be fetched.");
|
|
205
|
+
await keepOriginal(urlKey, fetched, meta);
|
|
206
|
+
return fetched;
|
|
207
|
+
} catch (error) {
|
|
208
|
+
failedUntil.set(urlKey, now() + FAILED_MS);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
/** One size of the original, made once and kept. A GIF is passed through: resizing one keeps its first frame. */
|
|
214
|
+
const makeVariant = (key, source, width, format) => resizing(key, async () => {
|
|
215
|
+
let body = source.body;
|
|
216
|
+
let type = source.meta.type;
|
|
217
|
+
if (transform && type !== "image/gif") try {
|
|
218
|
+
body = await transform(source.body, width, format);
|
|
219
|
+
type = format === "original" ? type : `image/${format}`;
|
|
220
|
+
} catch {
|
|
221
|
+
body = source.body;
|
|
222
|
+
type = source.meta.type;
|
|
223
|
+
}
|
|
224
|
+
await write(variantFile(key, SOURCE_TYPES[type]), body);
|
|
225
|
+
return {
|
|
226
|
+
body,
|
|
227
|
+
type
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
return async ({ url, width, accept = "" }) => {
|
|
231
|
+
let source;
|
|
232
|
+
try {
|
|
233
|
+
source = new URL(url ?? "");
|
|
234
|
+
} catch {
|
|
235
|
+
return answer(400, "`url` is the picture’s address.");
|
|
236
|
+
}
|
|
237
|
+
if (source.protocol !== "https:" && source.protocol !== "http:") return answer(400, "`url` is the picture’s web address.");
|
|
238
|
+
const requested = Number(width);
|
|
239
|
+
if (!isImageWidth(requested)) return answer(400, `\`w\` is one of ${IMAGE_WIDTHS.join(", ")}.`);
|
|
240
|
+
if (!isAllowedImageHost(domains, source.hostname)) return answer(403, `${source.hostname} is not one of the hosts this server takes pictures from.`);
|
|
241
|
+
const format = transform ? negotiate(accept) : "original";
|
|
242
|
+
const urlKey = sha(source.href);
|
|
243
|
+
try {
|
|
244
|
+
const kept = await readMeta(urlKey);
|
|
245
|
+
if (kept && now() - kept.checkedAt >= FRESH_MS) revalidate(urlKey, source, kept);
|
|
246
|
+
const made = kept && await keptVariant(variantKey(urlKey, kept.version, requested, format));
|
|
247
|
+
if (made) return {
|
|
248
|
+
status: 200,
|
|
249
|
+
headers: headersOf(made.type),
|
|
250
|
+
body: made.body
|
|
251
|
+
};
|
|
252
|
+
const from = await original(urlKey, source);
|
|
253
|
+
const variant = await makeVariant(variantKey(urlKey, from.meta.version, requested, format), from, requested, format);
|
|
254
|
+
return {
|
|
255
|
+
status: 200,
|
|
256
|
+
headers: headersOf(variant.type),
|
|
257
|
+
body: variant.body
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof Refusal) return error.answer;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
var headersOf = (type) => ({
|
|
266
|
+
"Content-Type": type,
|
|
267
|
+
"Cache-Control": CACHE_CONTROL,
|
|
268
|
+
Vary: "Accept",
|
|
269
|
+
"X-Content-Type-Options": "nosniff",
|
|
270
|
+
"Content-Security-Policy": "default-src 'none'; sandbox"
|
|
271
|
+
});
|
|
272
|
+
//#endregion
|
|
273
|
+
export { createImageProxy };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resizing and re-encoding, by `sharp` when the deployment installed it. An optional peer, as `isolated-vm` is: a
|
|
3
|
+
* native module this package does not impose on a server that resizes nothing.
|
|
4
|
+
*/
|
|
5
|
+
/** The output a picture is encoded in: what the browser said it takes, or the picture's own format. */
|
|
6
|
+
export type ImageFormat = 'avif' | 'webp' | 'original';
|
|
7
|
+
/** Resizes `input` to `width` (never larger than it is) in `format`. */
|
|
8
|
+
export type ImageTransform = (input: Buffer, width: number, format: ImageFormat) => Promise<Buffer>;
|
|
9
|
+
/** The transform `sharp` makes, or `undefined` when it is not installed. */
|
|
10
|
+
export declare const loadSharpTransform: () => Promise<ImageTransform | undefined>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/modules/images/transform.ts
|
|
2
|
+
var isSharpFactory = (value) => typeof value === "function";
|
|
3
|
+
/** The transform `sharp` makes, or `undefined` when it is not installed. */
|
|
4
|
+
var loadSharpTransform = async () => {
|
|
5
|
+
const name = "sharp";
|
|
6
|
+
let loaded;
|
|
7
|
+
try {
|
|
8
|
+
loaded = await import(
|
|
9
|
+
/* @vite-ignore */
|
|
10
|
+
name
|
|
11
|
+
);
|
|
12
|
+
} catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const factory = typeof loaded === "object" && loaded !== null && "default" in loaded ? loaded.default : loaded;
|
|
16
|
+
if (!isSharpFactory(factory)) return;
|
|
17
|
+
return async (input, width, format) => {
|
|
18
|
+
const pipeline = factory(input).rotate().resize({
|
|
19
|
+
width,
|
|
20
|
+
withoutEnlargement: true
|
|
21
|
+
});
|
|
22
|
+
if (format === "avif") return pipeline.avif({ quality: 55 }).toBuffer();
|
|
23
|
+
if (format === "webp") return pipeline.webp({ quality: 75 }).toBuffer();
|
|
24
|
+
return pipeline.toBuffer();
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { loadSharpTransform };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -3,7 +3,7 @@ import { ActionLookups } from '../actions/types';
|
|
|
3
3
|
import { ConnectorLookups } from '../connectors/resolver';
|
|
4
4
|
import { SSRAdapters } from '@plitzi/sdk-shared';
|
|
5
5
|
/**
|
|
6
|
-
* `getRscData`, built from the producers the server was already given — connectors, actions,
|
|
6
|
+
* `getRscData`, built from the producers the server was already given — connectors, actions, its own static files.
|
|
7
7
|
*
|
|
8
8
|
* Every deployment that served server-driven elements wrote this same adapter: await the render payload, check the
|
|
9
9
|
* schema opted into RSC, then hand `resolveRscData` a resolver over the lookups — all of which are this package's
|
|
@@ -17,9 +17,14 @@ import { SSRAdapters } from '@plitzi/sdk-shared';
|
|
|
17
17
|
*
|
|
18
18
|
* A deployment that resolves server elements some other way still supplies its own `getRscData`, and that one wins.
|
|
19
19
|
*/
|
|
20
|
-
export declare const connectorRscData: (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
export declare const connectorRscData: ({ connectors, actions, publicDir, elementTimeoutMs }: {
|
|
21
|
+
connectors?: ConnectorLookups;
|
|
22
|
+
actions?: {
|
|
23
|
+
lookups: ActionLookups;
|
|
24
|
+
module: ActionsModule;
|
|
25
|
+
};
|
|
26
|
+
/** Where the server's static files are: a provider whose `query` is one of them is read from disk. */
|
|
27
|
+
publicDir?: string;
|
|
28
|
+
/** The deployment's per-element ceiling, when it set one. `resolveRscData` decides the default. */
|
|
29
|
+
elementTimeoutMs?: number;
|
|
30
|
+
}) => NonNullable<SSRAdapters["getRscData"]>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createActionResolver } from "../actions/runtime/renderResolver.js";
|
|
2
2
|
import { createConnectorResolver } from "../connectors/resolver.js";
|
|
3
|
+
import { publicFileResolver } from "./publicFileResolver.js";
|
|
3
4
|
import { resolveRscData } from "./resolveRscData.js";
|
|
4
5
|
//#region src/modules/rsc/connectorRscData.ts
|
|
5
6
|
/**
|
|
6
|
-
* `getRscData`, built from the producers the server was already given — connectors, actions,
|
|
7
|
+
* `getRscData`, built from the producers the server was already given — connectors, actions, its own static files.
|
|
7
8
|
*
|
|
8
9
|
* Every deployment that served server-driven elements wrote this same adapter: await the render payload, check the
|
|
9
10
|
* schema opted into RSC, then hand `resolveRscData` a resolver over the lookups — all of which are this package's
|
|
@@ -17,22 +18,24 @@ import { resolveRscData } from "./resolveRscData.js";
|
|
|
17
18
|
*
|
|
18
19
|
* A deployment that resolves server elements some other way still supplies its own `getRscData`, and that one wins.
|
|
19
20
|
*/
|
|
20
|
-
var connectorRscData = (
|
|
21
|
-
const resolveConnector =
|
|
21
|
+
var connectorRscData = ({ connectors, actions, publicDir, elementTimeoutMs }) => {
|
|
22
|
+
const resolveConnector = connectors ? createConnectorResolver(connectors) : void 0;
|
|
22
23
|
const resolveAction = actions ? createActionResolver(actions.lookups, actions.module) : void 0;
|
|
24
|
+
const resolvePublicFile = publicDir ? publicFileResolver(publicDir) : void 0;
|
|
23
25
|
/**
|
|
24
26
|
* An element names ONE producer, and which one decides how its data is fetched.
|
|
25
27
|
*
|
|
26
|
-
* A connector is the declarative read and stays the default; an action is the read a manifest cannot express
|
|
27
|
-
*
|
|
28
|
-
* keeps resolving the way its page already renders, instead of silently changing under them.
|
|
28
|
+
* A connector is the declarative read and stays the default; an action is the read a manifest cannot express; a
|
|
29
|
+
* file of the server's own is the read with no backend at all. Checked in that order so an element carrying more
|
|
30
|
+
* than one keeps resolving the way its page already renders, instead of silently changing under them.
|
|
29
31
|
*/
|
|
30
32
|
const resolveElement = async (context) => {
|
|
31
33
|
const attributes = context.element.attributes;
|
|
32
34
|
if (attributes.connector) return resolveConnector ? resolveConnector(context) : void 0;
|
|
33
|
-
|
|
35
|
+
if (attributes.action) return resolveAction ? resolveAction(context) : void 0;
|
|
36
|
+
return resolvePublicFile ? resolvePublicFile(context) : void 0;
|
|
34
37
|
};
|
|
35
|
-
return async ({ req, spaceId, environment, user, ids, loadOfflineData }) => {
|
|
38
|
+
return async ({ req, spaceId, environment, user, ids, loadOfflineData, flagOverrides }) => {
|
|
36
39
|
const offlineData = await loadOfflineData();
|
|
37
40
|
if (!offlineData?.schema.rsc?.enabled) return {};
|
|
38
41
|
return resolveRscData({
|
|
@@ -43,6 +46,7 @@ var connectorRscData = (lookups, actions, elementTimeoutMs) => {
|
|
|
43
46
|
user,
|
|
44
47
|
ids,
|
|
45
48
|
resolveElement,
|
|
49
|
+
...flagOverrides ? { flagOverrides } : {},
|
|
46
50
|
...elementTimeoutMs === void 0 ? {} : { timeoutMs: elementTimeoutMs }
|
|
47
51
|
});
|
|
48
52
|
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { serverLog } from "../../helpers/serverLog.js";
|
|
2
|
+
import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
|
|
2
3
|
import { resolveDebugAuthorization } from "../../helpers/debugAuthorization.js";
|
|
3
4
|
import { DEFAULT_TTL_MS } from "../../helpers/cache/defaults.js";
|
|
4
5
|
import { buildRscCacheKey } from "../../helpers/cache/keys.js";
|
|
5
6
|
import { readDraftToken } from "../../core/previewToken.js";
|
|
6
7
|
import { createOfflineDataLoader } from "../../helpers/offlineDataLoader.js";
|
|
8
|
+
import { forcedFlagsFromCookies } from "@plitzi/sdk-shared/flags";
|
|
7
9
|
//#region src/modules/rsc/handler.ts
|
|
8
10
|
/** Bounds the reflected location: it only ever selects one of this space's own pages, but it is still input. */
|
|
9
11
|
var MAX_PAGE_LOCATION = 2048;
|
|
@@ -93,10 +95,16 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
|
|
|
93
95
|
* refresh that arrives a second after it expired is still part of the same preview.
|
|
94
96
|
*/
|
|
95
97
|
const previewing = readDraftToken(req) !== void 0;
|
|
98
|
+
/**
|
|
99
|
+
* A tester forcing flags from the dev tools sees data for features nobody else has on — and must never be handed a
|
|
100
|
+
* slice resolved without them. Their refreshes go around the cache both ways, like a draft's.
|
|
101
|
+
*/
|
|
102
|
+
const forcingFlags = Object.keys(forcedFlagsFromCookies(req.headers.cookie, req.headers.host)).length > 0;
|
|
103
|
+
const uncached = previewing || forcingFlags;
|
|
96
104
|
const ttlMs = config.rsc?.cacheTtlMs ?? DEFAULT_TTL_MS.rsc;
|
|
97
105
|
const isAuthenticated = !!req.ctx.user;
|
|
98
|
-
const cacheControl = environment === "main" ||
|
|
99
|
-
const cacheKey = environment !== "main" && !
|
|
106
|
+
const cacheControl = environment === "main" || uncached ? "no-store" : isAuthenticated ? `private, max-age=${Math.floor(ttlMs / 1e3)}` : `public, max-age=${Math.floor(ttlMs / 1e3)}`;
|
|
107
|
+
const cacheKey = environment !== "main" && !uncached ? buildRscCacheKey(spaceId, environment, revision, req.ctx.user?.id, idsParam, req) : void 0;
|
|
100
108
|
const cached = cacheKey ? cache?.get(cacheKey) : void 0;
|
|
101
109
|
if (!previewing) await meterRsc(req, config, spaceId, environment, revision, !!cached);
|
|
102
110
|
if (cached) {
|
|
@@ -107,6 +115,11 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
|
|
|
107
115
|
return;
|
|
108
116
|
}
|
|
109
117
|
const loadOfflineData = createOfflineDataLoader(() => config.adapters.getOfflineData(spaceId, environment, revision));
|
|
118
|
+
const settings = async () => (await loadOfflineData())?.schema.settings;
|
|
119
|
+
const flagOverrides = await requestFlagOverrides(config, req, {
|
|
120
|
+
spaceId,
|
|
121
|
+
environment
|
|
122
|
+
}, () => resolveDebugAuthorization(config, settings));
|
|
110
123
|
let rscData;
|
|
111
124
|
try {
|
|
112
125
|
rscData = await config.adapters.getRscData({
|
|
@@ -116,7 +129,8 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
|
|
|
116
129
|
revision,
|
|
117
130
|
user: req.ctx.user,
|
|
118
131
|
ids,
|
|
119
|
-
loadOfflineData
|
|
132
|
+
loadOfflineData,
|
|
133
|
+
flagOverrides
|
|
120
134
|
});
|
|
121
135
|
} catch (err) {
|
|
122
136
|
serverLog.error("RSC", "getRscData error", err);
|
|
@@ -124,7 +138,7 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
|
|
|
124
138
|
res.send(JSON.stringify({ error: "RSC data fetch failed" }));
|
|
125
139
|
return;
|
|
126
140
|
}
|
|
127
|
-
const actionRuns = Boolean(req.ctx.actionRuns?.length) && await resolveDebugAuthorization(config,
|
|
141
|
+
const actionRuns = Boolean(req.ctx.actionRuns?.length) && await resolveDebugAuthorization(config, settings) ? req.ctx.actionRuns : void 0;
|
|
128
142
|
const payload = {
|
|
129
143
|
version: 1,
|
|
130
144
|
transport: "json",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RscElementResolver } from './resolveRscData';
|
|
2
|
+
/**
|
|
3
|
+
* A server provider whose `query` is a file this server serves from `publicDir` — `/data/home.json` — read from disk
|
|
4
|
+
* and handed to the render, so the page arrives with the section in it.
|
|
5
|
+
*
|
|
6
|
+
* A project with no backend keeps its data in `public/data/*.json`, and an `apiContainer` fetching it from the browser
|
|
7
|
+
* leaves the server-rendered page without those sections: a flash of nothing, no content for a crawler, and no
|
|
8
|
+
* anchor in place for `/#section` to land on. Read here, it is the same bytes the browser would have fetched.
|
|
9
|
+
*
|
|
10
|
+
* Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
|
|
11
|
+
* in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
|
|
12
|
+
*/
|
|
13
|
+
export declare const publicFileResolver: (publicDir: string) => RscElementResolver;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
//#region src/modules/rsc/publicFileResolver.ts
|
|
4
|
+
/**
|
|
5
|
+
* A server provider whose `query` is a file this server serves from `publicDir` — `/data/home.json` — read from disk
|
|
6
|
+
* and handed to the render, so the page arrives with the section in it.
|
|
7
|
+
*
|
|
8
|
+
* A project with no backend keeps its data in `public/data/*.json`, and an `apiContainer` fetching it from the browser
|
|
9
|
+
* leaves the server-rendered page without those sections: a flash of nothing, no content for a crawler, and no
|
|
10
|
+
* anchor in place for `/#section` to land on. Read here, it is the same bytes the browser would have fetched.
|
|
11
|
+
*
|
|
12
|
+
* Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
|
|
13
|
+
* in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
|
|
14
|
+
*/
|
|
15
|
+
var publicFileResolver = (publicDir) => {
|
|
16
|
+
const root = path.resolve(publicDir);
|
|
17
|
+
return async ({ element, signal }) => {
|
|
18
|
+
const query = element.attributes.query;
|
|
19
|
+
if (typeof query !== "string" || !query.startsWith("/") || query.startsWith("//") || query.includes("{{")) return;
|
|
20
|
+
let pathname;
|
|
21
|
+
try {
|
|
22
|
+
pathname = decodeURIComponent(query.split(/[?#]/)[0]);
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const file = path.resolve(root, `.${pathname}`);
|
|
27
|
+
if (!file.startsWith(`${root}${path.sep}`)) return;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(file, {
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
signal
|
|
32
|
+
}));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { publicFileResolver };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Element, Environment, Schema, SSRRequest, SSRRscData, SSRUser } from '@plitzi/sdk-shared';
|
|
1
|
+
import { Element, Environment, FlagOverrides, Schema, SSRRequest, SSRRscData, SSRUser } from '@plitzi/sdk-shared';
|
|
2
2
|
/** Everything an element resolver needs to turn one `runtime: 'server'` element into its data slice. */
|
|
3
3
|
export type RscResolveContext = {
|
|
4
4
|
element: Element;
|
|
@@ -30,6 +30,8 @@ export type ResolveRscDataOptions = {
|
|
|
30
30
|
/** Restricts resolution to these element ids (partial refresh). Undefined resolves every server element. */
|
|
31
31
|
ids?: string[];
|
|
32
32
|
resolveElement: RscElementResolver;
|
|
33
|
+
/** The layers above the space that decide its flags for this request — see `SSRRscContext.flagOverrides`. */
|
|
34
|
+
flagOverrides?: FlagOverrides;
|
|
33
35
|
/**
|
|
34
36
|
* Per-element budget: one slow provider must not hold the whole payload, and must not go on working once it
|
|
35
37
|
* has. It is the PAGE's ceiling and it wins over the producer's own — an action allowed ten seconds of its own
|
|
@@ -45,4 +47,4 @@ export type ResolveRscDataOptions = {
|
|
|
45
47
|
* and the route params are what the browser will see. Each `runtime: 'server'` element resolves independently:
|
|
46
48
|
* a provider that fails or hangs costs its own slice and nothing else.
|
|
47
49
|
*/
|
|
48
|
-
export declare const resolveRscData: ({ schema, req, spaceId, environment, user, ids, resolveElement, timeoutMs }: ResolveRscDataOptions) => Promise<SSRRscData>;
|
|
50
|
+
export declare const resolveRscData: ({ schema, req, spaceId, environment, user, ids, resolveElement, flagOverrides, timeoutMs }: ResolveRscDataOptions) => Promise<SSRRscData>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { serverLog } from "../../helpers/serverLog.js";
|
|
2
2
|
import { matchRscPage } from "./matchRscPage.js";
|
|
3
|
+
import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
|
|
3
4
|
import { collectServerElements } from "@plitzi/sdk-shared/schema/serverElements";
|
|
4
5
|
//#region src/modules/rsc/resolveRscData.ts
|
|
5
6
|
var DEFAULT_ELEMENT_TIMEOUT_MS = 5e3;
|
|
@@ -31,12 +32,19 @@ var withBudget = async (run, timeoutMs, label) => {
|
|
|
31
32
|
* and the route params are what the browser will see. Each `runtime: 'server'` element resolves independently:
|
|
32
33
|
* a provider that fails or hangs costs its own slice and nothing else.
|
|
33
34
|
*/
|
|
34
|
-
var resolveRscData = async ({ schema, req, spaceId, environment, user, ids, resolveElement, timeoutMs = DEFAULT_ELEMENT_TIMEOUT_MS }) => {
|
|
35
|
+
var resolveRscData = async ({ schema, req, spaceId, environment, user, ids, resolveElement, flagOverrides, timeoutMs = DEFAULT_ELEMENT_TIMEOUT_MS }) => {
|
|
35
36
|
if (schema.rsc?.enabled === false) return {};
|
|
36
37
|
const match = matchRscPage(schema, req.path, user);
|
|
37
38
|
if (!match) return { serverData: {} };
|
|
38
39
|
const { pageId, routeParams } = match;
|
|
39
|
-
const
|
|
40
|
+
const flags = flagValues(resolveFlags(schema.flags, {
|
|
41
|
+
environment,
|
|
42
|
+
hostname: req.hostname,
|
|
43
|
+
routeParams,
|
|
44
|
+
queryParams: req.query,
|
|
45
|
+
user: flagUserFromSSR(user)
|
|
46
|
+
}, flagOverrides));
|
|
47
|
+
const targets = collectServerElements(schema, pageId, ids, flags);
|
|
40
48
|
if (targets.length === 0) return { serverData: {} };
|
|
41
49
|
return { serverData: (await Promise.allSettled(targets.map(async (element) => ({
|
|
42
50
|
id: element.id,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import esbuild from "esbuild";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
6
7
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
7
8
|
//#region src/modules/runtime/bundle.ts
|
|
8
9
|
/** What the runtime's host provides, and a bundle must not carry a copy of: the platform's packages and React. */
|
|
@@ -50,7 +51,6 @@ var packRuntime = async (entry) => {
|
|
|
50
51
|
if (bytes.byteLength > 33554432) throw new Error(`A runtime is at most ${String(MAX_RUNTIME_BUNDLE_BYTES / 1024 / 1024)} MB packed; this one is ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB`);
|
|
51
52
|
return new Uint8Array(bytes);
|
|
52
53
|
};
|
|
53
|
-
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
54
|
/** A packed runtime read back — refused whole when it is not one. */
|
|
55
55
|
var unpack = (bytes) => {
|
|
56
56
|
let parsed;
|
|
@@ -15,6 +15,10 @@ export type ComponentProps = {
|
|
|
15
15
|
theme?: Theme;
|
|
16
16
|
/** The kept state the first paint depends on (`settings.paintedState`), from the visitor's cookie. */
|
|
17
17
|
state?: Record<string, unknown>;
|
|
18
|
+
/** The flags this deployment decides (`config.flags`). */
|
|
19
|
+
serverFlags?: Record<string, boolean>;
|
|
20
|
+
/** The flags a tester forced, for a page allowed to debug. See `prepareRender`. */
|
|
21
|
+
forcedFlags?: Record<string, boolean>;
|
|
18
22
|
};
|
|
19
|
-
declare const Component: ({ server, renderMode, previewMode, offlineData, environment, plugins, ssrResult, sdkDevToolsStylePath, debugMode, overQuota, theme, state }: ComponentProps) => import('react').JSX.Element;
|
|
23
|
+
declare const Component: ({ server, renderMode, previewMode, offlineData, environment, plugins, ssrResult, sdkDevToolsStylePath, debugMode, overQuota, theme, state, serverFlags, forcedFlags }: ComponentProps) => import('react').JSX.Element;
|
|
20
24
|
export default Component;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import PlitziSdk from "@plitzi/plitzi-sdk";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
//#region src/modules/ssr/Component.tsx
|
|
4
|
-
var Component = ({ server, renderMode = "raw", previewMode = true, offlineData, environment = "main", plugins, ssrResult, sdkDevToolsStylePath, debugMode = false, overQuota, theme, state }) => {
|
|
4
|
+
var Component = ({ server, renderMode = "raw", previewMode = true, offlineData, environment = "main", plugins, ssrResult, sdkDevToolsStylePath, debugMode = false, overQuota, theme, state, serverFlags, forcedFlags }) => {
|
|
5
5
|
const serverWithResult = {
|
|
6
6
|
...server,
|
|
7
7
|
ssr: {
|
|
@@ -21,6 +21,8 @@ var Component = ({ server, renderMode = "raw", previewMode = true, offlineData,
|
|
|
21
21
|
...overQuota === void 0 ? {} : { overQuota },
|
|
22
22
|
...theme === void 0 ? {} : { theme },
|
|
23
23
|
...state === void 0 ? {} : { state },
|
|
24
|
+
...serverFlags === void 0 ? {} : { serverFlags },
|
|
25
|
+
...forcedFlags === void 0 ? {} : { forcedFlags },
|
|
24
26
|
children: plugins && Object.keys(plugins).map((key) => /* @__PURE__ */ jsx(PlitziSdk.Plugin, {
|
|
25
27
|
renderType: key,
|
|
26
28
|
component: plugins[key].component,
|