@plitzi/sdk-server 0.37.9 → 0.37.10

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 (101) hide show
  1. package/CHANGELOG.md +589 -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/fleet/channel.js +1 -1
  18. package/dist/core/server/fleet/relay.js +1 -1
  19. package/dist/core/services/action.js +5 -4
  20. package/dist/core/services/registry.js +2 -0
  21. package/dist/helpers/cache/keys.d.ts +23 -10
  22. package/dist/helpers/cache/keys.js +16 -2
  23. package/dist/helpers/flagOverrides.d.ts +27 -0
  24. package/dist/helpers/flagOverrides.js +25 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +2 -1
  27. package/dist/modules/actions/moduleFor.js +5 -3
  28. package/dist/modules/actions/runtime/flags.d.ts +16 -0
  29. package/dist/modules/actions/runtime/flags.js +49 -0
  30. package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
  31. package/dist/modules/actions/runtime/kvList.js +1 -1
  32. package/dist/modules/actions/runtime/runAction.js +2 -0
  33. package/dist/modules/actions/transport/callHandler.js +8 -0
  34. package/dist/modules/actions/types.d.ts +14 -1
  35. package/dist/modules/connectors/engine.js +1 -1
  36. package/dist/modules/functions/capabilities.js +1 -1
  37. package/dist/modules/functions/load.js +2 -2
  38. package/dist/modules/functions/manifest.js +1 -1
  39. package/dist/modules/functions/runner/messages.js +1 -1
  40. package/dist/modules/images/allowlist.d.ts +5 -0
  41. package/dist/modules/images/allowlist.js +18 -0
  42. package/dist/modules/images/imageProxy.d.ts +36 -0
  43. package/dist/modules/images/imageProxy.js +273 -0
  44. package/dist/modules/images/imageProxy.test.d.ts +1 -0
  45. package/dist/modules/images/transform.d.ts +10 -0
  46. package/dist/modules/images/transform.js +28 -0
  47. package/dist/modules/images/transform.test.d.ts +1 -0
  48. package/dist/modules/rsc/connectorRscData.d.ts +12 -7
  49. package/dist/modules/rsc/connectorRscData.js +12 -8
  50. package/dist/modules/rsc/handler.js +18 -4
  51. package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
  52. package/dist/modules/rsc/publicFileResolver.js +39 -0
  53. package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
  54. package/dist/modules/rsc/resolveRscData.d.ts +4 -2
  55. package/dist/modules/rsc/resolveRscData.js +10 -2
  56. package/dist/modules/runtime/bundle.js +2 -2
  57. package/dist/modules/ssr/Component.d.ts +5 -1
  58. package/dist/modules/ssr/Component.js +3 -1
  59. package/dist/modules/ssr/prepareRender.js +36 -8
  60. package/package.json +9 -4
  61. package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
  62. package/skills/plitzi-authoring/SKILL.md +71 -71
  63. package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
  64. package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
  65. package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
  66. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
  67. package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
  68. package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
  69. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
  70. package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
  71. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
  72. package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
  73. package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
  74. package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
  75. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
  76. package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
  77. package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
  78. package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
  79. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
  80. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  81. package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
  82. package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
  83. package/skills/plitzi-authoring/reference/components.md +9 -1
  84. package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
  85. package/skills/plitzi-authoring/reference/efficiency.md +53 -0
  86. package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
  87. package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
  88. package/skills/plitzi-authoring/reference/flows.md +25 -36
  89. package/skills/plitzi-authoring/reference/kept-state.md +28 -0
  90. package/skills/plitzi-authoring/reference/layouts.md +20 -3
  91. package/skills/plitzi-authoring/reference/lists.md +45 -16
  92. package/skills/plitzi-authoring/reference/plugins.md +15 -0
  93. package/skills/plitzi-authoring/reference/realtime.md +35 -0
  94. package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
  95. package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +9 -8
  96. package/skills/plitzi-authoring/reference/structure.md +29 -20
  97. package/skills/plitzi-authoring/reference/tailwind.md +19 -0
  98. package/skills/plitzi-authoring/reference/templates.md +6 -5
  99. package/skills/plitzi-authoring/reference/testing.md +11 -0
  100. package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
  101. package/skills/plitzi-authoring/reference/validation.md +6 -2
package/README.md CHANGED
@@ -169,6 +169,7 @@ runtime, and its files into `public/` — a draft, or any published snapshot (`-
169
169
  | `devMode` | `boolean` | `false` | Enables development mode: appends `?dev` to esm.sh CDN URLs for React, and activates per-request timing metrics (see [Dev metrics](#dev-metrics)). Off, the process must run with `NODE_ENV=production` or React renders with its development build; the server says so once, at `error`. |
170
170
  | `assetVersion` | `string` | — | Cache-buster appended as `?v=<assetVersion>` to all default SDK asset URLs. Compute from file mtime or package version at startup. |
171
171
  | `cacheTtlMs` | `number` | `300000` | TTL in milliseconds for the SSR render cache. Set to `0` to disable. |
172
+ | `flags` | `Record<string, boolean> \| ({ spaceId, environment }) => Record<string, boolean>` | — | This deployment's say over the spaces' feature flags: above what a space declares, below the SDK's `flags` prop and a tester's dev tools, and only for flags the space declares. A function answers per space. Server actions and RSC resolve with it too. See `docs/en/feature-flags.md` in the workspace. |
172
173
  | `loginPath` | `string \| false` | `'/auth/login'` | Path for the built-in login endpoint. Set to `false` to disable it entirely. |
173
174
  | `logoutPath` | `string \| false` | `'/auth/logout'` | Path for the built-in logout endpoint. Set to `false` to disable it entirely. |
174
175
  | `signIn` | `SSRSignInConfig` | — | Sign visitors in by redirect through an OAuth 2.1 authorization server: `GET /auth/sign-in?return=/x` registers this host as a client, sends the browser out with PKCE (state in a `__Host-` cookie), and `/auth/sign-in/callback` redeems the code server to server and hands the token to `exchangeCredential`, which makes the session here. Needs the `exchangeCredential` adapter. |
@@ -180,6 +181,7 @@ runtime, and its files into `public/` — a draft, or any published snapshot (`-
180
181
  | `allowPrivatePluginHosts` | `boolean` | `false` | Read a schema plugin from a private address (`localhost`, this server's own network). A plugin's address is typed by whoever edits a space, so leave it off anywhere but a development machine whose bucket is local. |
181
182
  | `publicDir` | `string` | — | Absolute path to a directory served at the root URL level (e.g. `robots.txt`, `favicon.png`). Files are checked before `static` prefix routes. |
182
183
  | `static` | `Record<string, string>` | — | URL prefix → filesystem path mappings for static file serving. |
184
+ | `images` | `{ domains, cacheDir? }` | — | Pictures from other sites resized at `/_plitzi/img`, for an `image` to offer as a `srcset` (see [Images](#images)). |
183
185
  | `ssrOnly` | `boolean` | `false` | Omit client-side JS from the rendered page. Useful for verifying SSR HTML without hydration. |
184
186
  | `streaming` | `boolean` | `false` | Stream HTML to the browser incrementally to reduce TTFB. See [Streaming](#streaming). |
185
187
  | `middlewares` | `SSRMiddleware[]` | — | Array of custom middleware functions executed before the SSR renderer on every request (see [Custom middlewares](#custom-middlewares)). |
@@ -386,6 +388,21 @@ createServer({
386
388
 
387
389
  Static responses include `ETag`, `Last-Modified`, and `Cache-Control` headers. Subsequent requests with `If-None-Match` receive `304 Not Modified` when the file has not changed. JS, CSS, and font files are served with `Cache-Control: immutable`; all other assets use a 1-hour max-age.
388
390
 
391
+ ### Images
392
+
393
+ ```ts
394
+ createServer({ images: { domains: ['images.example.com', '*.cdn.example.com'] }, adapters: { ... } });
395
+ ```
396
+
397
+ An `image` whose `src` is another site's then offers the browser a `srcset` of widths this server makes (320 to 1920
398
+ px) at `/_plitzi/img?url=…&w=…`, in AVIF or WebP when the browser takes them. Everything is kept on disk (`cacheDir`,
399
+ default `.plitzi/images`): each original is downloaded once, and each size is made once from it. A week on, the kept
400
+ files keep answering while the original is asked for with its `ETag` / `Last-Modified` — unchanged, nothing is resized
401
+ again; changed, its sizes are made anew. A picture that could not be fetched is not asked for again for five minutes.
402
+ Only the listed hosts are fetched, every redirect is held to the same
403
+ list and to the rule every outbound request follows (no private network), and SVG is refused. Resizing needs `sharp`
404
+ (an optional peer): without it pictures are passed through and kept, and the server says so once.
405
+
389
406
  ### Public directory
390
407
 
391
408
  Use `publicDir` to serve your own root-level files (e.g. `robots.txt`, `sitemap.xml`, `favicon.png`) without prefixes:
@@ -35,7 +35,8 @@ export type CloudAdaptersConfig = {
35
35
  * revisions, it has whatever the builder last saved.
36
36
  *
37
37
  * Pinned to a number, this deployment serves that exact version until somebody changes the config: a revision
38
- * cannot change, so it is fetched once and kept for the life of the process. That is what a deployment wants
38
+ * cannot change, so it is fetched once and kept for the life of the process. Its feature flags are not part of it
39
+ * and still follow the environment — a flag turned in production reaches a pinned deployment too. That is what a deployment wants
39
40
  * when it rolls forward on its own schedule, or when it has to be able to say precisely what it is serving.
40
41
  *
41
42
  * Left out, it serves the **latest** revision of that environment and notices when a new one is published: a
@@ -52,8 +53,9 @@ export type CloudAdaptersConfig = {
52
53
  * - A **pinned revision** never expires. Expiring an immutable document on a timer would be paying for a
53
54
  * question whose answer is already known.
54
55
  *
55
- * So this paces exactly one thing: the "which revision is current" probe, in latest mode. Even then it never
56
- * sits in front of a visitor — the copy already held is served while the probe runs behind it.
56
+ * So this paces exactly one thing: the probe — "which revision is current" in latest mode, and in both modes "what
57
+ * are the flags at", whose answer is a hash and whose flags are fetched only when it moved. It never sits in front
58
+ * of a visitor: the copy already held is served while the probe runs behind it.
57
59
  */
58
60
  cacheSeconds?: number;
59
61
  /** Where fetched spaces are kept. Omitted, they are kept in this process only — see {@link CloudSpaceCache}. */
@@ -1,4 +1,6 @@
1
1
  import { serverLog } from "../helpers/serverLog.js";
2
+ import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
3
+ import { schemaFromWire } from "@plitzi/sdk-shared/schema/wire";
2
4
  //#region src/adapters/cloudAdapters.ts
3
5
  /**
4
6
  * A space fetched from Plitzi, for a server that is not Plitzi's.
@@ -28,9 +30,10 @@ var SPACE_QUERY = `query InitQuery($environment: String!, $revision: Int) {
28
30
  schema {
29
31
  settings
30
32
  rsc
31
- flat { id definition { label type initialState styleSelectors bindings interactions parentId rootId items runtime loadStrategy } attributes }
33
+ flat { id definition { label type initialState styleSelectors bindings interactions parentId rootId items runtime loadStrategy flag { name is } anchor } attributes }
32
34
  pages
33
35
  components
36
+ flags
34
37
  pageFolders { id name slug parentId }
35
38
  variables { name type value subValues { when value } }
36
39
  }
@@ -48,28 +51,19 @@ var SPACE_QUERY = `query InitQuery($environment: String!, $revision: Int) {
48
51
  var LATEST_REVISION_QUERY = `query SpaceLatestRevisionQuery($environment: String!) {
49
52
  SpaceLatestRevision(environment: $environment) {
50
53
  snapshot { revision }
54
+ flagsHash
51
55
  }
52
56
  }`;
53
57
  /**
54
- * GraphQL answers `flat` as a LIST; every reader of a schema indexes it BY ID (`schema.flat[pageId]`, and the same
55
- * for `parentId`/`rootId`/`items`). The two are the `SchemaRaw` and `Schema` types, and the conversion is the wire
56
- * format's whole difference from the runtime one.
57
- *
58
- * Without it nothing throws — an array is a perfectly good object — it just answers `undefined` to every lookup, so
59
- * the page router matches no page and every URL is a 404 on a space that fetched and parsed correctly.
58
+ * The environment's feature flags. Asked only when the probe's `flagsHash` moved: flags change apart from revisions —
59
+ * one set per environment, shared by every revision it serves — so they are fetched and kept on their own, and a flag
60
+ * turned in production costs this deployment a few hundred bytes rather than the space again.
60
61
  */
61
- var byElementId = (flat) => {
62
- if (!Array.isArray(flat)) return flat ?? {};
63
- return flat.reduce((acum, element) => {
64
- if (element.id) acum[element.id] = element;
65
- return acum;
66
- }, {});
67
- };
68
- /** The runtime shape of a schema: the wire's `flat` list, keyed. */
69
- var toSchema = (schema) => ({
70
- ...schema,
71
- flat: byElementId(schema.flat)
72
- });
62
+ var FLAGS_QUERY = `query SpaceFlagsQuery($environment: String!) {
63
+ SpaceFlags(environment: $environment) { flags hash }
64
+ }`;
65
+ /** What the shared cache holds under a flags key — written by this module, but read back from outside the process. */
66
+ var isHeldFlags = (value) => isRecord(value) && typeof value.hash === "string" && isRecord(value.flags);
73
67
  /**
74
68
  * The `scope` claim, read without verifying anything.
75
69
  *
@@ -147,15 +141,74 @@ var createCloudAdapters = (config) => {
147
141
  }, `space ${env}@${rev ?? "latest"}`))?.Space;
148
142
  if (!space?.schema) return;
149
143
  return {
150
- schema: toSchema(space.schema),
144
+ schema: schemaFromWire(space.schema),
151
145
  style: space.style,
152
146
  plugins: space.plugins
153
147
  };
154
148
  };
155
- const fetchLatestRevision = async (env) => {
156
- return (await post(LATEST_REVISION_QUERY, { environment: env }, `latest revision of ${env}`))?.SpaceLatestRevision?.snapshot?.revision;
149
+ /** Which revision is current, and what the flags are at — one small question for both halves of a served space. */
150
+ const probe = async (env) => {
151
+ const answer = (await post(LATEST_REVISION_QUERY, { environment: env }, `latest revision of ${env}`))?.SpaceLatestRevision;
152
+ return {
153
+ revision: answer?.snapshot?.revision,
154
+ flagsHash: answer?.flagsHash ?? void 0
155
+ };
157
156
  };
158
157
  const cacheKey = (env, rev) => `plitzi:space:${env}:${rev}`;
158
+ const flagsCacheKey = (env) => `plitzi:flags:${env}`;
159
+ /** Each environment's flags as last fetched, and when the probe last confirmed them. */
160
+ const heldFlags = /* @__PURE__ */ new Map();
161
+ /** The flags this process last held, or — on a cold start — the shared cache's, so a restart with Plitzi down keeps
162
+ * serving the flags it knew rather than the ones frozen into a cached revision. */
163
+ const restoreFlags = async (env) => {
164
+ if (heldFlags.has(env)) return;
165
+ try {
166
+ const stored = await cache?.get(flagsCacheKey(env));
167
+ const held = stored ? JSON.parse(stored) : void 0;
168
+ if (isHeldFlags(held)) heldFlags.set(env, {
169
+ ...held,
170
+ checkedAt: 0
171
+ });
172
+ } catch {}
173
+ };
174
+ /** Brings the flags up to the hash the probe answered, fetching them only when it moved. A failed probe or fetch keeps
175
+ * the flags already held. */
176
+ const syncFlags = async (env, hash) => {
177
+ if (hash === void 0) return;
178
+ const current = heldFlags.get(env);
179
+ if (current?.hash === hash) {
180
+ current.checkedAt = Date.now();
181
+ return;
182
+ }
183
+ const fetched = (await post(FLAGS_QUERY, { environment: env }, `flags of ${env}`))?.SpaceFlags;
184
+ if (!fetched) return;
185
+ heldFlags.set(env, {
186
+ ...fetched,
187
+ checkedAt: Date.now()
188
+ });
189
+ await cache?.set(flagsCacheKey(env), JSON.stringify(fetched)).catch(() => void 0);
190
+ };
191
+ /** A served space with the flags held for its environment — the same object for the same pair, so the page server
192
+ * serialises it once rather than per request. */
193
+ const combined = /* @__PURE__ */ new WeakMap();
194
+ const withFlags = (env, data) => {
195
+ const flags = heldFlags.get(env);
196
+ if (!flags) return data;
197
+ const known = combined.get(data);
198
+ if (known?.hash === flags.hash) return known.data;
199
+ const joined = {
200
+ ...data,
201
+ schema: {
202
+ ...data.schema,
203
+ flags: flags.flags
204
+ }
205
+ };
206
+ combined.set(data, {
207
+ hash: flags.hash,
208
+ data: joined
209
+ });
210
+ return joined;
211
+ };
159
212
  const held = /* @__PURE__ */ new Map();
160
213
  /** One request per key at a time: a burst of renders on a cold cache joins the first, it does not multiply it. */
161
214
  const inFlight = /* @__PURE__ */ new Map();
@@ -203,7 +256,9 @@ var createCloudAdapters = (config) => {
203
256
  * A probe that fails leaves `checkedAt` alone so the next request tries again, and leaves the held copy serving.
204
257
  */
205
258
  const refreshLatest = async (env) => {
206
- const current = await fetchLatestRevision(env);
259
+ await restoreFlags(env);
260
+ const { revision: current, flagsHash } = await probe(env);
261
+ await syncFlags(env, flagsHash);
207
262
  const previous = held.get(env);
208
263
  if (current === void 0) return previous?.data;
209
264
  if (previous && previous.revision === current) {
@@ -213,10 +268,27 @@ var createCloudAdapters = (config) => {
213
268
  return await loadRevision(env, current) ?? previous?.data;
214
269
  };
215
270
  /**
271
+ * A pinned revision's flags: waited for once — the first render must not go out with the flags frozen into the copy
272
+ * it fetched — and then checked behind the answer, on the window.
273
+ */
274
+ const refreshPinnedFlags = async (env) => {
275
+ await restoreFlags(env);
276
+ const flags = heldFlags.get(env);
277
+ if (!flags) {
278
+ await syncFlags(env, (await probe(env)).flagsHash);
279
+ return;
280
+ }
281
+ if (Date.now() - flags.checkedAt >= cacheSeconds * 1e3) {
282
+ flags.checkedAt = Date.now();
283
+ probe(env).then(({ flagsHash }) => syncFlags(env, flagsHash)).catch(() => void 0);
284
+ }
285
+ };
286
+ /**
216
287
  * The space, and how hard this deployment leans on Plitzi to get it.
217
288
  *
218
289
  * - **`main`**: read live, every time. It is being edited; a cached answer is a wrong one.
219
- * - **A pinned revision**: from cache forever after the first read. It cannot change.
290
+ * - **A pinned revision**: from cache forever after the first read. It cannot change — its flags can, so they are
291
+ * probed on the same window as the latest mode's revision, and fetched only when they moved.
220
292
  * - **Latest**: the held copy is served immediately and a revision probe runs behind it once the window is up.
221
293
  * After the first render no page ever waits on Plitzi, and a failed probe or fetch changes nothing — the last
222
294
  * good copy keeps serving, because a self-hosted site going blank over somebody else's bad minute is not a
@@ -226,16 +298,21 @@ var createCloudAdapters = (config) => {
226
298
  if (isLive(env)) return once(`live:${env}`, () => fetchSpace(env));
227
299
  const pinned = rev ?? revision;
228
300
  if (pinned !== void 0 && isPinned(env, pinned)) {
301
+ await refreshPinnedFlags(env);
229
302
  const already = held.get(env);
230
- return already?.revision === pinned ? already.data : loadRevision(env, pinned);
303
+ const data = already?.revision === pinned ? already.data : await loadRevision(env, pinned);
304
+ return data && withFlags(env, data);
231
305
  }
232
306
  const current = held.get(env);
233
- if (!current) return refreshLatest(env);
307
+ if (!current) {
308
+ const data = await refreshLatest(env);
309
+ return data && withFlags(env, data);
310
+ }
234
311
  if (Date.now() - current.checkedAt >= cacheSeconds * 1e3) {
235
312
  current.checkedAt = Date.now();
236
313
  refreshLatest(env).catch(() => void 0);
237
314
  }
238
- return current.data;
315
+ return withFlags(env, current.data);
239
316
  };
240
317
  /**
241
318
  * Every request is this one space.
@@ -244,7 +321,7 @@ var createCloudAdapters = (config) => {
244
321
  * builds two servers or supplies its own resolver — which is more honest than a lookup table that silently
245
322
  * serves the wrong space when a domain is added and this is not.
246
323
  *
247
- * The revision it reports is the one actually being served, which in latest mode is whatever the last probe
324
+ * The revision it reports is the one actually being served, with what its flags are at, which in latest mode is whatever the last probe
248
325
  * found: a deployment reading `at: { environment, revision }` must be told the version its page was built from,
249
326
  * not the zero that means "whatever is live".
250
327
  */
@@ -255,18 +332,27 @@ var createCloudAdapters = (config) => {
255
332
  environment,
256
333
  revision: 0
257
334
  };
258
- if (isPinned(environment, revision)) return {
259
- ...deployment,
260
- spaceId,
261
- environment,
262
- revision
335
+ const flagsVersion = () => {
336
+ const flags = heldFlags.get(environment);
337
+ return flags ? { flagsVersion: flags.hash } : {};
263
338
  };
339
+ if (isPinned(environment, revision)) {
340
+ await refreshPinnedFlags(environment);
341
+ return {
342
+ ...deployment,
343
+ spaceId,
344
+ environment,
345
+ revision,
346
+ ...flagsVersion()
347
+ };
348
+ }
264
349
  if (!held.has(environment)) await refreshLatest(environment);
265
350
  return {
266
351
  ...deployment,
267
352
  spaceId,
268
353
  environment,
269
- revision: held.get(environment)?.revision ?? 0
354
+ revision: held.get(environment)?.revision ?? 0,
355
+ ...flagsVersion()
270
356
  };
271
357
  };
272
358
  return {
@@ -20,6 +20,9 @@ export interface SpaceResolution {
20
20
  templateProps?: SSRTemplateProps;
21
21
  pluginNames?: string[];
22
22
  pluginSources?: Record<string, PluginSource>;
23
+ /** What the space's flags are at, when they change apart from its revisions — see `SSRSpaceDeployment.flagsVersion`.
24
+ * Usually set by `decorate`, which runs on every request: a cached resolution would hold a stale one. */
25
+ flagsVersion?: string;
23
26
  }
24
27
  /** A resolver that has decided this request gets nothing, and that no later resolver should be asked. */
25
28
  export interface SpaceRefusal {
@@ -0,0 +1,14 @@
1
+ import { ActionDbDriver, ActionLookups, ActionsConfig } from '../modules/actions/types';
2
+ import { ConnectorLookups } from '../modules/connectors/resolver';
3
+ import { ActionLookupsConfig, ConnectorLookupsConfig } from '@plitzi/sdk-shared';
4
+ /** The connector lookups a deployment configured, each manifest checked as it is read. One per config object. */
5
+ export declare const connectorLookupsOf: (config: ConnectorLookupsConfig) => ConnectorLookups;
6
+ /**
7
+ * The action lookups a deployment configured, each document checked as it is read. Asked for one, a document that is
8
+ * not one is refused; listed, it is left out and said, so one broken action does not take its space's schedule down.
9
+ */
10
+ export declare const actionLookupsOf: (config: ActionLookupsConfig) => ActionLookups;
11
+ /** The deployment's database drivers, each held to what a `db` task calls — or why one is not, as the server starts. */
12
+ export declare const dbDriversOf: (drivers: readonly unknown[] | undefined) => ActionDbDriver[] | undefined;
13
+ /** The deployment's functions config, held to what runs a space's code — or why it is not, as the server starts. */
14
+ export declare const functionsConfigOf: (config: unknown) => ActionsConfig["functions"];
@@ -0,0 +1,114 @@
1
+ import { serverLog } from "../helpers/serverLog.js";
2
+ import { isActionDocument, validateActionDocument } from "@plitzi/sdk-shared/actions";
3
+ import { isConnectorManifestDraft, validateConnectorManifest } from "@plitzi/sdk-shared/connectors";
4
+ import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
5
+ //#region src/core/configSeam.ts
6
+ /**
7
+ * A guard that checks each object once: a deployment that caches what it reads hands back the same object on every
8
+ * call, and a render must not pay for validating its action again.
9
+ */
10
+ var checkedOnce = (guard) => {
11
+ const seen = /* @__PURE__ */ new WeakMap();
12
+ return (value) => {
13
+ if (!isRecord(value)) return false;
14
+ const known = seen.get(value);
15
+ if (known !== void 0) return known;
16
+ const valid = guard(value);
17
+ seen.set(value, valid);
18
+ return valid;
19
+ };
20
+ };
21
+ var isManifest = checkedOnce(isConnectorManifestDraft);
22
+ var isDocument = checkedOnce(isActionDocument);
23
+ /** What `getFunctions` answers: built by `@plitzi/sdk-server/functions` from a space's saved bundle, a live object. */
24
+ var isSpaceFunctions = (value) => isRecord(value) && isRecord(value.bundle) && typeof value.bundle.id === "string" && typeof value.bundle.load === "function" && isRecord(value.manifest);
25
+ /** Why a document is not one this server runs: the first problem its validator found, by where it is. */
26
+ var firstProblem = (errors) => {
27
+ const first = errors.at(0);
28
+ if (!first) return "it is not a document";
29
+ return first.path ? `${first.path}: ${first.message}` : first.message;
30
+ };
31
+ var manifestFrom = (found, spaceId, connectorId) => {
32
+ if (found === void 0 || found === null) return;
33
+ if (!isManifest(found)) throw new Error(`Connector "${connectorId}" of space ${String(spaceId)} is not a manifest this server can run — ${firstProblem(validateConnectorManifest(found).errors)}`);
34
+ return {
35
+ ...found,
36
+ id: connectorId
37
+ };
38
+ };
39
+ /** An action as a lookup found it: the entry, or why it is not one. */
40
+ var readAction = (found) => {
41
+ const document = isRecord(found) ? found.document : void 0;
42
+ if (!isRecord(found) || typeof found.id !== "string") return { problem: "it has no id" };
43
+ return isDocument(document) ? { entry: {
44
+ id: found.id,
45
+ document
46
+ } } : { problem: firstProblem(validateActionDocument(document).errors) };
47
+ };
48
+ var connectorSeams = /* @__PURE__ */ new WeakMap();
49
+ /** The connector lookups a deployment configured, each manifest checked as it is read. One per config object. */
50
+ var connectorLookupsOf = (config) => {
51
+ const existing = connectorSeams.get(config);
52
+ if (existing) return existing;
53
+ const lookups = {
54
+ getConnector: async (spaceId, connectorId, at) => manifestFrom(await config.getConnector(spaceId, connectorId, at), spaceId, connectorId),
55
+ ...config.getCredential ? { getCredential: config.getCredential } : {},
56
+ ...config.fetchImpl ? { fetchImpl: config.fetchImpl } : {}
57
+ };
58
+ connectorSeams.set(config, lookups);
59
+ return lookups;
60
+ };
61
+ /**
62
+ * The action lookups a deployment configured, each document checked as it is read. Asked for one, a document that is
63
+ * not one is refused; listed, it is left out and said, so one broken action does not take its space's schedule down.
64
+ */
65
+ var actionLookupsOf = (config) => {
66
+ const { getConnector, getFunctions, listActions } = config;
67
+ return {
68
+ getAction: async (spaceId, actionId, at) => {
69
+ const found = await config.getAction(spaceId, actionId, at);
70
+ if (found === void 0 || found === null) return;
71
+ const read = readAction(found);
72
+ if ("problem" in read) throw new Error(`Action "${actionId}" of space ${String(spaceId)} is not a document this server can run — ${read.problem}`);
73
+ return read.entry;
74
+ },
75
+ ...listActions ? { listActions: async (spaceId, at) => (await listActions(spaceId, at)).flatMap((found) => {
76
+ const read = readAction(found);
77
+ if ("problem" in read) {
78
+ serverLog.warn("actions", `An action of space ${String(spaceId)} is left out — ${read.problem}`);
79
+ return [];
80
+ }
81
+ return [read.entry];
82
+ }) } : {},
83
+ ...config.listScheduledSpaces ? { listScheduledSpaces: config.listScheduledSpaces } : {},
84
+ ...config.getCredential ? { getCredential: config.getCredential } : {},
85
+ ...getConnector ? { getConnector: async (spaceId, connectorId, at) => manifestFrom(await getConnector(spaceId, connectorId, at), spaceId, connectorId) } : {},
86
+ ...getFunctions ? { getFunctions: async (spaceId, at) => {
87
+ const found = await getFunctions(spaceId, at);
88
+ if (found === void 0 || found === null) return;
89
+ if (!isSpaceFunctions(found)) throw new Error(`The functions of space ${String(spaceId)} are not what @plitzi/sdk-server/functions builds for one`);
90
+ return found;
91
+ } } : {},
92
+ ...config.getFlags ? { getFlags: config.getFlags } : {}
93
+ };
94
+ };
95
+ var isFunction = (value) => typeof value === "function";
96
+ /** A driver as `createDbDrivers` builds one: the engine a credential names, and how to query it. */
97
+ var isDbDriver = (value) => isRecord(value) && typeof value.engine === "string" && isFunction(value.query);
98
+ /** The functions config as `@plitzi/sdk-server/functions` builds it: native definitions, a runner, ceilings. */
99
+ var isFunctionsConfig = (value) => isRecord(value) && (value.native === void 0 || Array.isArray(value.native) && value.native.every(isRecord)) && (value.runner === void 0 || isRecord(value.runner) && isFunction(value.runner.describe) && isFunction(value.runner.invoke)) && (value.limits === void 0 || isRecord(value.limits));
100
+ /** The deployment's database drivers, each held to what a `db` task calls — or why one is not, as the server starts. */
101
+ var dbDriversOf = (drivers) => {
102
+ if (drivers === void 0) return;
103
+ const wrong = drivers.findIndex((driver) => !isDbDriver(driver));
104
+ if (wrong >= 0) throw new Error(`action.dbDrivers[${String(wrong)}] is not a database driver: it needs an \`engine\` and a \`query\` function.`);
105
+ return drivers.filter(isDbDriver);
106
+ };
107
+ /** The deployment's functions config, held to what runs a space's code — or why it is not, as the server starts. */
108
+ var functionsConfigOf = (config) => {
109
+ if (config === void 0) return;
110
+ if (!isFunctionsConfig(config)) throw new Error("`functions` is not what @plitzi/sdk-server/functions configures: `native` definitions, a `runner` with `describe` and `invoke`, `limits`.");
111
+ return config;
112
+ };
113
+ //#endregion
114
+ export { actionLookupsOf, connectorLookupsOf, dbDriversOf, functionsConfigOf };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import { actionLookupsOf, connectorLookupsOf } from "./configSeam.js";
1
2
  import { actionsModuleFor } from "../modules/actions/moduleFor.js";
2
3
  import { connectorRscData } from "../modules/rsc/connectorRscData.js";
3
4
  import { createAuthApiStage } from "./http/stages/authApi.js";
@@ -12,8 +13,8 @@ import { createPageServer } from "./server/pageServer.js";
12
13
  * A dedicated MCP server is `createServer` from `@plitzi/sdk-mcp` — it builds none of the render template,
13
14
  * caches or plugin manager this one does. */
14
15
  /**
15
- * Fills in `getRscData` from whatever can produce a server element's data — `connectors`, `action.lookups`, or
16
- * both — when the deployment did not write one.
16
+ * Fills in `getRscData` from whatever can produce a server element's data — `connectors`, `action.lookups`, the
17
+ * files of `publicDir` — when the deployment did not write one.
17
18
  *
18
19
  * The lookups are already here and the assembly is entirely this package's — a resolver over `resolveRscData`.
19
20
  * Leaving it out meant every deployment passed the same lookups twice: once as config, for the write endpoint,
@@ -24,17 +25,22 @@ import { createPageServer } from "./server/pageServer.js";
24
25
  * with no configuration missing anywhere.
25
26
  */
26
27
  var withConnectorRsc = (config) => {
27
- if (config.adapters.getRscData || !config.connectors && !config.action?.lookups) return config;
28
+ if (config.adapters.getRscData || !config.connectors && !config.action?.lookups && !config.publicDir) return config;
28
29
  const resolved = {
29
30
  ...config,
30
31
  adapters: { ...config.adapters }
31
32
  };
32
33
  const module = actionsModuleFor(resolved);
33
34
  const actions = module && config.action?.lookups ? {
34
- lookups: config.action.lookups,
35
+ lookups: actionLookupsOf(config.action.lookups),
35
36
  module
36
37
  } : void 0;
37
- resolved.adapters.getRscData = connectorRscData(config.connectors, actions, config.rsc?.elementTimeoutMs);
38
+ resolved.adapters.getRscData = connectorRscData({
39
+ connectors: config.connectors ? connectorLookupsOf(config.connectors) : void 0,
40
+ actions,
41
+ publicDir: config.publicDir,
42
+ elementTimeoutMs: config.rsc?.elementTimeoutMs
43
+ });
38
44
  return resolved;
39
45
  };
40
46
  /** The renewal stage in front of whatever else gates itself, when there is anywhere to renew. */
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The first port from `preferred` up that nothing uses — for a development server, which should start beside
3
+ * whatever else is running rather than refuse to.
4
+ *
5
+ * Free means both that nothing answers there and that this process may listen there: a server bound to every
6
+ * address leaves `127.0.0.1` bindable on some systems, and a page server started beside it would never be reached —
7
+ * the other one would answer, with a `200` that looks like success. A deployment that sets its port does not need
8
+ * this: a fixed port that is taken is an error worth stopping for.
9
+ */
10
+ export declare const freePort: (preferred: number, host?: string, attempts?: number) => Promise<number>;
@@ -0,0 +1,36 @@
1
+ import { connect, createServer } from "node:net";
2
+ //#region src/core/freePort.ts
3
+ /** Whether something already answers at `port` — the one test that sees a listener on every address, not just `host`. */
4
+ var answers = (port, host) => new Promise((resolve) => {
5
+ const socket = connect({
6
+ port,
7
+ host
8
+ });
9
+ socket.once("connect", () => {
10
+ socket.destroy();
11
+ resolve(true);
12
+ });
13
+ socket.once("error", () => resolve(false));
14
+ });
15
+ /** Whether this process could listen at `port` on `host`. */
16
+ var bindable = (port, host) => new Promise((resolve) => {
17
+ const probe = createServer();
18
+ probe.once("error", () => resolve(false));
19
+ probe.once("listening", () => probe.close(() => resolve(true)));
20
+ probe.listen(port, host);
21
+ });
22
+ /**
23
+ * The first port from `preferred` up that nothing uses — for a development server, which should start beside
24
+ * whatever else is running rather than refuse to.
25
+ *
26
+ * Free means both that nothing answers there and that this process may listen there: a server bound to every
27
+ * address leaves `127.0.0.1` bindable on some systems, and a page server started beside it would never be reached —
28
+ * the other one would answer, with a `200` that looks like success. A deployment that sets its port does not need
29
+ * this: a fixed port that is taken is an error worth stopping for.
30
+ */
31
+ var freePort = async (preferred, host = "127.0.0.1", attempts = 20) => {
32
+ for (let port = preferred; port < preferred + attempts; port += 1) if (!await answers(port, host) && await bindable(port, host)) return port;
33
+ throw new Error(`No free port between ${preferred} and ${preferred + attempts - 1} on ${host}. Set PORT to one.`);
34
+ };
35
+ //#endregion
36
+ export { freePort };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import { Stage } from '../types';
2
+ import { SSRServerConfig } from '@plitzi/sdk-shared';
3
+ /** Where pictures are resized, when this server resizes them: published to the SDK, which builds every `srcset` on it. */
4
+ export declare const imagesPathOf: (config: Pick<SSRServerConfig, "images">) => string | undefined;
5
+ /** `/_plitzi/img?url=…&w=…` — a remote picture at one of the widths the SDK asks for. See `SSRImagesConfig`. */
6
+ export declare const imagesStage: Stage;
@@ -0,0 +1,47 @@
1
+ import { serverLog } from "../../../helpers/serverLog.js";
2
+ import { createImageProxy } from "../../../modules/images/imageProxy.js";
3
+ import { loadSharpTransform } from "../../../modules/images/transform.js";
4
+ import path from "node:path";
5
+ import { IMAGE_PATH } from "@plitzi/sdk-shared/helpers/images";
6
+ //#region src/core/http/stages/images.ts
7
+ /** Where pictures are resized, when this server resizes them: published to the SDK, which builds every `srcset` on it. */
8
+ var imagesPathOf = (config) => config.images && config.images.domains.length > 0 ? IMAGE_PATH : void 0;
9
+ /** One proxy per configuration, made on the first request: `sharp` is looked for once, and said once when missing. */
10
+ var proxies = /* @__PURE__ */ new WeakMap();
11
+ var proxyFor = (images) => {
12
+ const known = proxies.get(images);
13
+ if (known) return known;
14
+ const made = loadSharpTransform().then((transform) => {
15
+ if (!transform) serverLog.warn("IMAGES", "sharp is not installed: pictures are passed through and kept, not resized (npm i sharp).");
16
+ return createImageProxy({
17
+ domains: images.domains,
18
+ cacheDir: path.resolve(images.cacheDir ?? ".plitzi/images"),
19
+ fetch: globalThis.fetch,
20
+ transform
21
+ });
22
+ });
23
+ proxies.set(images, made);
24
+ return made;
25
+ };
26
+ /** `/_plitzi/img?url=…&w=…` — a remote picture at one of the widths the SDK asks for. See `SSRImagesConfig`. */
27
+ var imagesStage = async (ctx) => {
28
+ const { images } = ctx.config;
29
+ if (!images || imagesPathOf(ctx.config) === void 0 || ctx.req.path !== IMAGE_PATH) return false;
30
+ if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") {
31
+ ctx.res.setStatus(405);
32
+ ctx.res.setHeader("Allow", "GET, HEAD");
33
+ ctx.res.send("Method not allowed");
34
+ return true;
35
+ }
36
+ const answer = await (await proxyFor(images))({
37
+ url: ctx.req.query.url,
38
+ width: ctx.req.query.w,
39
+ accept: ctx.req.headers.accept ?? ""
40
+ });
41
+ ctx.res.setStatus(answer.status);
42
+ for (const [name, value] of Object.entries(answer.headers)) ctx.res.setHeader(name, value);
43
+ ctx.res.send(answer.body);
44
+ return true;
45
+ };
46
+ //#endregion
47
+ export { imagesPathOf, imagesStage };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
+ import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
1
2
  //#region src/core/server/fleet/channel.ts
2
- var isRecord = (value) => typeof value === "object" && value !== null;
3
3
  /** The channel is shared with `node:cluster` and anything else the deployment sends: only these are ours. */
4
4
  var isFleetMessage = (value) => {
5
5
  if (!isRecord(value)) return false;
@@ -1,5 +1,6 @@
1
1
  import { serverLog } from "../../../helpers/serverLog.js";
2
2
  import { broadcast, onBroadcast } from "./link.js";
3
+ import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
3
4
  //#region src/core/server/fleet/relay.ts
4
5
  /**
5
6
  * `server.cache` and `server.plugins` in a fleet, whose workers each keep their own: what one process is told, every
@@ -10,7 +11,6 @@ import { broadcast, onBroadcast } from "./link.js";
10
11
  * Code that runs in every process (a pub/sub subscription made where the server is created) is told N times; what
11
12
  * is relayed is safe to apply again, and cheap: it only drops entries from memory.
12
13
  */
13
- var isRecord = (value) => typeof value === "object" && value !== null;
14
14
  var PLUGIN_ACTIONS = [
15
15
  "copy",
16
16
  "compile",