@plitzi/sdk-server 0.37.8 → 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 (127) hide show
  1. package/CHANGELOG.md +825 -0
  2. package/README.md +24 -2
  3. package/dist/actions.d.ts +1 -1
  4. package/dist/adapters/cloudAdapters.d.ts +5 -3
  5. package/dist/adapters/cloudAdapters.js +123 -63
  6. package/dist/adapters/space/types.d.ts +3 -0
  7. package/dist/core/configSeam.d.ts +14 -0
  8. package/dist/core/configSeam.js +114 -0
  9. package/dist/core/configSeam.test.d.ts +1 -0
  10. package/dist/core/createServer.js +11 -5
  11. package/dist/core/createServer.publicData.test.d.ts +1 -0
  12. package/dist/core/freePort.d.ts +10 -0
  13. package/dist/core/freePort.js +36 -0
  14. package/dist/core/freePort.test.d.ts +1 -0
  15. package/dist/core/http/stages/images.d.ts +6 -0
  16. package/dist/core/http/stages/images.js +47 -0
  17. package/dist/core/http/stages/images.test.d.ts +1 -0
  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/functionRoutes.d.ts +2 -2
  22. package/dist/core/services/functionRoutes.js +2 -2
  23. package/dist/core/services/registry.js +2 -0
  24. package/dist/functions-api.d.ts +23 -2
  25. package/dist/functions.d.ts +1 -1
  26. package/dist/functions.js +2 -2
  27. package/dist/helpers/cache/keys.d.ts +23 -10
  28. package/dist/helpers/cache/keys.js +16 -2
  29. package/dist/helpers/flagOverrides.d.ts +27 -0
  30. package/dist/helpers/flagOverrides.js +25 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +2 -1
  33. package/dist/modules/actions/index.d.ts +1 -1
  34. package/dist/modules/actions/index.js +3 -2
  35. package/dist/modules/actions/moduleFor.js +5 -3
  36. package/dist/modules/actions/runtime/flags.d.ts +16 -0
  37. package/dist/modules/actions/runtime/flags.js +49 -0
  38. package/dist/modules/actions/runtime/flags.test.d.ts +1 -0
  39. package/dist/modules/actions/runtime/kvList.js +1 -1
  40. package/dist/modules/actions/runtime/renderResolver.js +30 -4
  41. package/dist/modules/actions/runtime/runAction.js +2 -0
  42. package/dist/modules/actions/taskCatalog.d.ts +3 -1
  43. package/dist/modules/actions/taskCatalog.js +1 -0
  44. package/dist/modules/actions/tasks/registry.js +4 -2
  45. package/dist/modules/actions/transport/callHandler.js +8 -0
  46. package/dist/modules/actions/types.d.ts +20 -1
  47. package/dist/modules/connectors/engine.js +1 -1
  48. package/dist/modules/functions/capabilities.js +1 -1
  49. package/dist/modules/functions/config.d.ts +19 -3
  50. package/dist/modules/functions/config.js +30 -14
  51. package/dist/modules/functions/contract.d.ts +10 -3
  52. package/dist/modules/functions/driver.js +4 -2
  53. package/dist/modules/functions/load.js +2 -2
  54. package/dist/modules/functions/manifest.d.ts +5 -3
  55. package/dist/modules/functions/manifest.js +37 -6
  56. package/dist/modules/functions/routes.d.ts +1 -1
  57. package/dist/modules/functions/routes.js +3 -3
  58. package/dist/modules/functions/runner/messages.js +1 -1
  59. package/dist/modules/functions/space.d.ts +7 -5
  60. package/dist/modules/functions/space.js +17 -10
  61. package/dist/modules/images/allowlist.d.ts +5 -0
  62. package/dist/modules/images/allowlist.js +18 -0
  63. package/dist/modules/images/imageProxy.d.ts +36 -0
  64. package/dist/modules/images/imageProxy.js +273 -0
  65. package/dist/modules/images/imageProxy.test.d.ts +1 -0
  66. package/dist/modules/images/transform.d.ts +10 -0
  67. package/dist/modules/images/transform.js +28 -0
  68. package/dist/modules/images/transform.test.d.ts +1 -0
  69. package/dist/modules/rsc/connectorRscData.d.ts +12 -7
  70. package/dist/modules/rsc/connectorRscData.js +12 -8
  71. package/dist/modules/rsc/handler.js +18 -4
  72. package/dist/modules/rsc/publicFileResolver.d.ts +13 -0
  73. package/dist/modules/rsc/publicFileResolver.js +39 -0
  74. package/dist/modules/rsc/publicFileResolver.test.d.ts +1 -0
  75. package/dist/modules/rsc/resolveRscData.d.ts +4 -2
  76. package/dist/modules/rsc/resolveRscData.js +10 -2
  77. package/dist/modules/runtime/bundle.js +2 -2
  78. package/dist/modules/runtime/contract.d.ts +1 -1
  79. package/dist/modules/runtime/stages.d.ts +1 -1
  80. package/dist/modules/runtime/stages.js +1 -1
  81. package/dist/modules/ssr/Component.d.ts +5 -1
  82. package/dist/modules/ssr/Component.js +3 -1
  83. package/dist/modules/ssr/prepareRender.js +38 -10
  84. package/dist/modules/ssr/registerExternalPlugins.d.ts +5 -1
  85. package/dist/modules/ssr/registerExternalPlugins.js +6 -2
  86. package/package.json +9 -4
  87. package/skills/plitzi-authoring/CHEATSHEET.md +135 -0
  88. package/skills/plitzi-authoring/SKILL.md +77 -74
  89. package/skills/plitzi-authoring/recipes/carousel.ts +114 -0
  90. package/skills/plitzi-authoring/recipes/detail-page.ts +70 -0
  91. package/skills/plitzi-authoring/recipes/embed-and-svg.ts +48 -0
  92. package/skills/plitzi-authoring/recipes/every-few-seconds.ts +119 -0
  93. package/skills/plitzi-authoring/recipes/feature-flag.ts +24 -0
  94. package/skills/plitzi-authoring/recipes/filter-a-list.ts +63 -0
  95. package/skills/plitzi-authoring/recipes/forms-and-modals.ts +64 -0
  96. package/skills/plitzi-authoring/recipes/from-tailwind.ts +52 -0
  97. package/skills/plitzi-authoring/recipes/link-to-a-section.ts +35 -0
  98. package/skills/plitzi-authoring/recipes/link-with-data.ts +40 -0
  99. package/skills/plitzi-authoring/recipes/marquee.ts +57 -0
  100. package/skills/plitzi-authoring/recipes/plugin.ts +53 -0
  101. package/skills/plitzi-authoring/recipes/scroll-a-row.ts +79 -0
  102. package/skills/plitzi-authoring/recipes/show-data.ts +73 -0
  103. package/skills/plitzi-authoring/recipes/style-a-page.ts +45 -0
  104. package/skills/plitzi-authoring/recipes/typed-data.ts +64 -0
  105. package/skills/plitzi-authoring/recipes/usable-without-sight.ts +39 -0
  106. package/skills/plitzi-authoring/reference/accessibility.md +2 -2
  107. package/skills/plitzi-authoring/reference/authoring-errors.md +195 -53
  108. package/skills/plitzi-authoring/reference/colours-and-motion.md +63 -0
  109. package/skills/plitzi-authoring/reference/components.md +83 -0
  110. package/skills/plitzi-authoring/reference/data-and-visibility.md +29 -29
  111. package/skills/plitzi-authoring/reference/efficiency.md +53 -0
  112. package/skills/plitzi-authoring/reference/elements-and-styles.md +39 -24
  113. package/skills/plitzi-authoring/reference/feature-flags.md +78 -0
  114. package/skills/plitzi-authoring/reference/flows.md +26 -37
  115. package/skills/plitzi-authoring/reference/kept-state.md +28 -0
  116. package/skills/plitzi-authoring/reference/layouts.md +20 -3
  117. package/skills/plitzi-authoring/reference/lists.md +45 -16
  118. package/skills/plitzi-authoring/reference/plugins.md +27 -0
  119. package/skills/plitzi-authoring/reference/realtime.md +35 -0
  120. package/skills/plitzi-authoring/reference/review-checklist.md +8 -3
  121. package/skills/plitzi-authoring/reference/{templates-and-export.md → snippets-and-export.md} +15 -8
  122. package/skills/plitzi-authoring/reference/structure.md +29 -20
  123. package/skills/plitzi-authoring/reference/tailwind.md +19 -0
  124. package/skills/plitzi-authoring/reference/templates.md +6 -5
  125. package/skills/plitzi-authoring/reference/testing.md +11 -0
  126. package/skills/plitzi-authoring/reference/typed-sources.md +20 -0
  127. package/skills/plitzi-authoring/reference/validation.md +6 -2
package/README.md CHANGED
@@ -155,6 +155,11 @@ adapters: createJsonAdapters({ offlineData: './space.json' })
155
155
  `validateSpace({ schema, style })` from `@plitzi/sdk-authoring` answers whether one is servable before you
156
156
  serve it — worth running over anything that arrives as a file.
157
157
 
158
+ A space on Plitzi comes out whole, as a project wired to this server: `npx @plitzi/cli create my-site --from <space>`
159
+ writes its pages as authoring code, its actions as `defineAction` code, its functions, the source of its plugins and
160
+ runtime, and its files into `public/` — a draft, or any published snapshot (`--environment`, `--revision`) — and
161
+ `plitzi pull` keeps it in step. See `docs/en/projects-from-spaces.md` in the workspace.
162
+
158
163
  ## Configuration
159
164
 
160
165
  | Option | Type | Default | Description |
@@ -164,6 +169,7 @@ serve it — worth running over anything that arrives as a file.
164
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`. |
165
170
  | `assetVersion` | `string` | — | Cache-buster appended as `?v=<assetVersion>` to all default SDK asset URLs. Compute from file mtime or package version at startup. |
166
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. |
167
173
  | `loginPath` | `string \| false` | `'/auth/login'` | Path for the built-in login endpoint. Set to `false` to disable it entirely. |
168
174
  | `logoutPath` | `string \| false` | `'/auth/logout'` | Path for the built-in logout endpoint. Set to `false` to disable it entirely. |
169
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. |
@@ -171,10 +177,11 @@ serve it — worth running over anything that arrives as a file.
171
177
  | `plugins` | `Record<string, PluginSource>` | — | Named plugin definitions. Compiled or copied on first use and cached for `pluginsTtlMs`. |
172
178
  | `pluginsCacheDir` | `string` | `.sdk-plugins` | Directory where compiled plugin files are stored. |
173
179
  | `pluginsTtlMs` | `number` | `604800000` | TTL in milliseconds for compiled plugins (default: 1 week). |
174
- | `autoLoadSchemaPlugins` | `boolean` | `true` | Auto-download and cache plugins declared in the schema's `offlineData.plugins` list. Set to `false` to manage plugin loading manually. |
180
+ | `autoLoadSchemaPlugins` | `boolean` | `true` | Auto-download and cache plugins declared in the schema's `offlineData.plugins` list. A type the deployment registers itself (`plugins`, named in `pluginNames`) is never looked for there: its own build is the one rendered. Set to `false` to manage plugin loading manually. |
175
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. |
176
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. |
177
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)). |
178
185
  | `ssrOnly` | `boolean` | `false` | Omit client-side JS from the rendered page. Useful for verifying SSR HTML without hydration. |
179
186
  | `streaming` | `boolean` | `false` | Stream HTML to the browser incrementally to reduce TTFB. See [Streaming](#streaming). |
180
187
  | `middlewares` | `SSRMiddleware[]` | — | Array of custom middleware functions executed before the SSR renderer on every request (see [Custom middlewares](#custom-middlewares)). |
@@ -213,7 +220,7 @@ type SSRAdapters = {
213
220
  };
214
221
  ```
215
222
 
216
- - **`getOfflineData`** — returns the space snapshot (schema, plugins, styles, segments, collections) for SSR.
223
+ - **`getOfflineData`** — returns the space snapshot (schema, plugins, styles, collections) for SSR.
217
224
  - **`getSpaceDeployment`** — resolves which space and environment to render for a given inbound request. Return `{ error: { code, message } }` to abort with an HTTP error. Optionally include `templateProps` to override template variables, or `pluginNames` to activate plugins for the space (see [Plugins](#plugins) and [Template props](#template-props)).
218
225
  - **`getUser`** *(optional)* — resolves the authenticated user from the inbound request (e.g. via a session cookie or `Authorization` header). Called in parallel with `getOfflineData` on every cache miss. Return `undefined` for unauthenticated requests. What it returns is inlined into the page — `authenticated`, `user.details`, the access token and its expiry — and that does two things: it decides page-level access for guest vs. registered users during the render, and it **saves the browser its first auth request**. The SDK adopts the inlined session on boot, stores it and schedules the renewal, rather than asking the same authority the same question a few milliseconds later. Implement this one adapter and every server-rendered page starts signed in with no round trip; `createAuth` answers it for you.
219
226
  - **`authenticate`** *(optional)* — called when `POST {loginPath}` is received, with the credentials already parsed from the body (a posted form or JSON), whatever fields they are. Return a session to grant one, `undefined` to refuse — and never throw for a wrong password. **Identity only**: the cookies, their lifetimes and the readable hint are the server's, which is what keeps a session established here visible to the API side and the other way round. For a navigation (full-page form submit, `Sec-Fetch-Mode: navigate`) the server answers a `303` so the view re-renders via a GET; for a fetch it answers `200` with `{ success, access_token, expire_at }` or `401` with `{ error, reason }` — never a bodyless status, which leaves a caller holding nothing. This route knows a session and not the account behind it; `createServer({ auth })` serves the same path from the auth kernel instead, which answers the full grant.
@@ -381,6 +388,21 @@ createServer({
381
388
 
382
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.
383
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
+
384
406
  ### Public directory
385
407
 
386
408
  Use `publicDir` to serve your own root-level files (e.g. `robots.txt`, `sitemap.xml`, `favicon.png`) without prefixes:
package/dist/actions.d.ts CHANGED
@@ -68,7 +68,7 @@ export type { KvListEntry, KvListPut, KvListPutOptions, KvListRange } from './mo
68
68
  */
69
69
  export { createRunLogger, createRejectLogger } from './modules/actions/runtime/runLogger';
70
70
  export type { TaskRegistryOptions } from './modules/actions/tasks/registry';
71
- export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionJobsConfig, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
71
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionJobsConfig, ActionKvAdapter, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskOrigin, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './modules/actions/types';
72
72
  export type { ActionsModule } from './modules/actions';
73
73
  export type { ActionJobs, JobWorker, JobWorkerOptions, MemoryJobQueueOptions, Scheduler, SchedulerOptions, SweepResult } from './modules/actions/jobs';
74
74
  export type { ActiveRun, RunGuards } from './modules/actions/runtime/guards';
@@ -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.
@@ -27,22 +29,14 @@ var SPACE_QUERY = `query InitQuery($environment: String!, $revision: Int) {
27
29
  Space(environment: $environment, revision: $revision) {
28
30
  schema {
29
31
  settings
30
- flat { id definition { label type initialState styleSelectors bindings interactions parentId rootId items } attributes }
32
+ rsc
33
+ flat { id definition { label type initialState styleSelectors bindings interactions parentId rootId items runtime loadStrategy flag { name is } anchor } attributes }
31
34
  pages
35
+ components
36
+ flags
32
37
  pageFolders { id name slug parentId }
33
38
  variables { name type value subValues { when value } }
34
39
  }
35
- segments {
36
- id
37
- identifier
38
- definition
39
- schema {
40
- settings
41
- variables { name type value subValues { value when } }
42
- flat { id definition { label type initialState styleSelectors bindings interactions parentId rootId items } attributes }
43
- }
44
- style { cache }
45
- }
46
40
  plugins { type resource settings }
47
41
  style { variables cache }
48
42
  }
@@ -57,44 +51,19 @@ var SPACE_QUERY = `query InitQuery($environment: String!, $revision: Int) {
57
51
  var LATEST_REVISION_QUERY = `query SpaceLatestRevisionQuery($environment: String!) {
58
52
  SpaceLatestRevision(environment: $environment) {
59
53
  snapshot { revision }
54
+ flagsHash
60
55
  }
61
56
  }`;
62
57
  /**
63
- * GraphQL answers `flat` as a LIST; every reader of a schema indexes it BY ID (`schema.flat[pageId]`, and the same
64
- * for `parentId`/`rootId`/`items`). The two are the `SchemaRaw` and `Schema` types, and the conversion is the wire
65
- * format's whole difference from the runtime one.
66
- *
67
- * Without it nothing throws — an array is a perfectly good object — it just answers `undefined` to every lookup, so
68
- * the page router matches no page and every URL is a 404 on a space that fetched and parsed correctly.
69
- */
70
- var byElementId = (flat) => {
71
- if (!Array.isArray(flat)) return flat ?? {};
72
- return flat.reduce((acum, element) => {
73
- if (element.id) acum[element.id] = element;
74
- return acum;
75
- }, {});
76
- };
77
- /** The runtime shape of a schema: the wire's `flat` list, keyed. */
78
- var toSchema = (schema) => ({
79
- ...schema,
80
- flat: byElementId(schema.flat)
81
- });
82
- /**
83
- * Segments arrive as a list and are read by identifier, which is how every other reader of a space holds them. Each
84
- * one carries a schema of its own, so its `flat` needs the same keying the space's does.
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.
85
61
  */
86
- var byIdentifier = (segments) => (segments ?? []).reduce((acum, segment) => {
87
- const entry = segment;
88
- const identifier = entry.identifier;
89
- if (!identifier || identifier in acum) return acum;
90
- return {
91
- ...acum,
92
- [identifier]: {
93
- ...entry,
94
- ...entry.schema && { schema: toSchema(entry.schema) }
95
- }
96
- };
97
- }, {});
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);
98
67
  /**
99
68
  * The `scope` claim, read without verifying anything.
100
69
  *
@@ -172,16 +141,74 @@ var createCloudAdapters = (config) => {
172
141
  }, `space ${env}@${rev ?? "latest"}`))?.Space;
173
142
  if (!space?.schema) return;
174
143
  return {
175
- schema: toSchema(space.schema),
144
+ schema: schemaFromWire(space.schema),
176
145
  style: space.style,
177
- plugins: space.plugins,
178
- segments: byIdentifier(space.segments)
146
+ plugins: space.plugins
179
147
  };
180
148
  };
181
- const fetchLatestRevision = async (env) => {
182
- 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
+ };
183
156
  };
184
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
+ };
185
212
  const held = /* @__PURE__ */ new Map();
186
213
  /** One request per key at a time: a burst of renders on a cold cache joins the first, it does not multiply it. */
187
214
  const inFlight = /* @__PURE__ */ new Map();
@@ -229,7 +256,9 @@ var createCloudAdapters = (config) => {
229
256
  * A probe that fails leaves `checkedAt` alone so the next request tries again, and leaves the held copy serving.
230
257
  */
231
258
  const refreshLatest = async (env) => {
232
- const current = await fetchLatestRevision(env);
259
+ await restoreFlags(env);
260
+ const { revision: current, flagsHash } = await probe(env);
261
+ await syncFlags(env, flagsHash);
233
262
  const previous = held.get(env);
234
263
  if (current === void 0) return previous?.data;
235
264
  if (previous && previous.revision === current) {
@@ -239,10 +268,27 @@ var createCloudAdapters = (config) => {
239
268
  return await loadRevision(env, current) ?? previous?.data;
240
269
  };
241
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
+ /**
242
287
  * The space, and how hard this deployment leans on Plitzi to get it.
243
288
  *
244
289
  * - **`main`**: read live, every time. It is being edited; a cached answer is a wrong one.
245
- * - **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.
246
292
  * - **Latest**: the held copy is served immediately and a revision probe runs behind it once the window is up.
247
293
  * After the first render no page ever waits on Plitzi, and a failed probe or fetch changes nothing — the last
248
294
  * good copy keeps serving, because a self-hosted site going blank over somebody else's bad minute is not a
@@ -252,16 +298,21 @@ var createCloudAdapters = (config) => {
252
298
  if (isLive(env)) return once(`live:${env}`, () => fetchSpace(env));
253
299
  const pinned = rev ?? revision;
254
300
  if (pinned !== void 0 && isPinned(env, pinned)) {
301
+ await refreshPinnedFlags(env);
255
302
  const already = held.get(env);
256
- 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);
257
305
  }
258
306
  const current = held.get(env);
259
- if (!current) return refreshLatest(env);
307
+ if (!current) {
308
+ const data = await refreshLatest(env);
309
+ return data && withFlags(env, data);
310
+ }
260
311
  if (Date.now() - current.checkedAt >= cacheSeconds * 1e3) {
261
312
  current.checkedAt = Date.now();
262
313
  refreshLatest(env).catch(() => void 0);
263
314
  }
264
- return current.data;
315
+ return withFlags(env, current.data);
265
316
  };
266
317
  /**
267
318
  * Every request is this one space.
@@ -270,7 +321,7 @@ var createCloudAdapters = (config) => {
270
321
  * builds two servers or supplies its own resolver — which is more honest than a lookup table that silently
271
322
  * serves the wrong space when a domain is added and this is not.
272
323
  *
273
- * 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
274
325
  * found: a deployment reading `at: { environment, revision }` must be told the version its page was built from,
275
326
  * not the zero that means "whatever is live".
276
327
  */
@@ -281,18 +332,27 @@ var createCloudAdapters = (config) => {
281
332
  environment,
282
333
  revision: 0
283
334
  };
284
- if (isPinned(environment, revision)) return {
285
- ...deployment,
286
- spaceId,
287
- environment,
288
- revision
335
+ const flagsVersion = () => {
336
+ const flags = heldFlags.get(environment);
337
+ return flags ? { flagsVersion: flags.hash } : {};
289
338
  };
339
+ if (isPinned(environment, revision)) {
340
+ await refreshPinnedFlags(environment);
341
+ return {
342
+ ...deployment,
343
+ spaceId,
344
+ environment,
345
+ revision,
346
+ ...flagsVersion()
347
+ };
348
+ }
290
349
  if (!held.has(environment)) await refreshLatest(environment);
291
350
  return {
292
351
  ...deployment,
293
352
  spaceId,
294
353
  environment,
295
- revision: held.get(environment)?.revision ?? 0
354
+ revision: held.get(environment)?.revision ?? 0,
355
+ ...flagsVersion()
296
356
  };
297
357
  };
298
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;