@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +615 -0
  2. package/README.md +17 -0
  3. package/dist/adapters/cloudAdapters.d.ts +5 -3
  4. package/dist/adapters/cloudAdapters.js +120 -34
  5. package/dist/adapters/space/types.d.ts +3 -0
  6. package/dist/core/configSeam.d.ts +14 -0
  7. package/dist/core/configSeam.js +114 -0
  8. package/dist/core/configSeam.test.d.ts +1 -0
  9. package/dist/core/createServer.js +11 -5
  10. package/dist/core/createServer.publicData.test.d.ts +1 -0
  11. package/dist/core/freePort.d.ts +10 -0
  12. package/dist/core/freePort.js +36 -0
  13. package/dist/core/freePort.test.d.ts +1 -0
  14. package/dist/core/http/stages/images.d.ts +6 -0
  15. package/dist/core/http/stages/images.js +47 -0
  16. package/dist/core/http/stages/images.test.d.ts +1 -0
  17. package/dist/core/server/drain.js +9 -4
  18. package/dist/core/server/fleet/channel.js +1 -1
  19. package/dist/core/server/fleet/relay.js +1 -1
  20. package/dist/core/services/action.js +5 -4
  21. package/dist/core/services/registry.js +2 -0
  22. package/dist/helpers/cache/keys.d.ts +23 -10
  23. package/dist/helpers/cache/keys.js +16 -2
  24. package/dist/helpers/flagOverrides.d.ts +27 -0
  25. package/dist/helpers/flagOverrides.js +25 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +2 -1
  28. package/dist/modules/actions/moduleFor.js +5 -3
  29. package/dist/modules/actions/runtime/flags.d.ts +16 -0
  30. package/dist/modules/actions/runtime/flags.js +49 -0
  31. package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
  32. package/dist/modules/actions/runtime/kvList.js +1 -1
  33. package/dist/modules/actions/runtime/runAction.js +2 -0
  34. package/dist/modules/actions/transport/callHandler.js +8 -0
  35. package/dist/modules/actions/types.d.ts +14 -1
  36. package/dist/modules/connectors/engine.js +1 -1
  37. package/dist/modules/functions/capabilities.js +1 -1
  38. package/dist/modules/functions/load.js +2 -2
  39. package/dist/modules/functions/manifest.js +1 -1
  40. package/dist/modules/functions/runner/messages.js +1 -1
  41. package/dist/modules/images/allowlist.d.ts +5 -0
  42. package/dist/modules/images/allowlist.js +18 -0
  43. package/dist/modules/images/imageProxy.d.ts +36 -0
  44. package/dist/modules/images/imageProxy.js +273 -0
  45. package/dist/modules/images/imageProxy.test.d.ts +1 -0
  46. package/dist/modules/images/transform.d.ts +10 -0
  47. package/dist/modules/images/transform.js +28 -0
  48. package/dist/modules/images/transform.test.d.ts +1 -0
  49. package/dist/modules/rsc/connectorRscData.d.ts +12 -7
  50. package/dist/modules/rsc/connectorRscData.js +12 -8
  51. package/dist/modules/rsc/handler.js +18 -4
  52. package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
  53. package/dist/modules/rsc/publicFileResolver.js +39 -0
  54. package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
  55. package/dist/modules/rsc/resolveRscData.d.ts +4 -2
  56. package/dist/modules/rsc/resolveRscData.js +10 -2
  57. package/dist/modules/runtime/bundle.js +2 -2
  58. package/dist/modules/ssr/Component.d.ts +5 -1
  59. package/dist/modules/ssr/Component.js +3 -1
  60. package/dist/modules/ssr/prepareRender.js +36 -8
  61. package/package.json +19 -14
  62. package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
  63. package/skills/plitzi-authoring/SKILL.md +71 -71
  64. package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
  65. package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
  66. package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
  67. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
  68. package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
  69. package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
  70. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
  71. package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
  72. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
  73. package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
  74. package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
  75. package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
  76. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
  77. package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
  78. package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
  79. package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
  80. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
  81. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  82. package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
  83. package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
  84. package/skills/plitzi-authoring/reference/components.md +9 -1
  85. package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
  86. package/skills/plitzi-authoring/reference/efficiency.md +53 -0
  87. package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
  88. package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
  89. package/skills/plitzi-authoring/reference/flows.md +25 -36
  90. package/skills/plitzi-authoring/reference/kept-state.md +28 -0
  91. package/skills/plitzi-authoring/reference/layouts.md +20 -3
  92. package/skills/plitzi-authoring/reference/lists.md +45 -16
  93. package/skills/plitzi-authoring/reference/plugins.md +15 -0
  94. package/skills/plitzi-authoring/reference/realtime.md +35 -0
  95. package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
  96. package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
  97. package/skills/plitzi-authoring/reference/structure.md +29 -20
  98. package/skills/plitzi-authoring/reference/tailwind.md +19 -0
  99. package/skills/plitzi-authoring/reference/templates.md +6 -5
  100. package/skills/plitzi-authoring/reference/testing.md +11 -0
  101. package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
  102. 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, or both.
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: (lookups?: ConnectorLookups, actions?: {
21
- lookups: ActionLookups;
22
- module: ActionsModule;
23
- },
24
- /** The deployment's per-element ceiling, when it set one. `resolveRscData` decides the default. */
25
- elementTimeoutMs?: number) => NonNullable<SSRAdapters["getRscData"]>;
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, or both.
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 = (lookups, actions, elementTimeoutMs) => {
21
- const resolveConnector = lookups ? createConnectorResolver(lookups) : void 0;
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
- * Checked in that order so an element carrying both — a connector element an author later pointed at an action —
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
- return attributes.action && resolveAction ? resolveAction(context) : void 0;
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" || previewing ? "no-store" : isAuthenticated ? `private, max-age=${Math.floor(ttlMs / 1e3)}` : `public, max-age=${Math.floor(ttlMs / 1e3)}`;
99
- const cacheKey = environment !== "main" && !previewing ? buildRscCacheKey(spaceId, environment, revision, req.ctx.user?.id, idsParam, req) : void 0;
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, async () => (await loadOfflineData())?.schema.settings) ? req.ctx.actionRuns : void 0;
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 targets = collectServerElements(schema, pageId, ids);
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,