@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
@@ -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",
@@ -1,3 +1,4 @@
1
+ import { connectorLookupsOf } from "../configSeam.js";
1
2
  import { handleActionCall } from "../../modules/actions/transport/callHandler.js";
2
3
  import { handleActionCatalog } from "../../modules/actions/transport/catalogHandler.js";
3
4
  import { handleActionWebhook } from "../../modules/actions/transport/webhookHandler.js";
@@ -38,8 +39,8 @@ var lineageOf = (req) => {
38
39
  var actionStage = async (ctx) => {
39
40
  const { config, req, actions } = ctx;
40
41
  const actionPath = config.action?.path ?? "/_action";
41
- const hasConnectors = !!config.connectors;
42
- if (!hasConnectors && !actions) return false;
42
+ const { connectors } = config;
43
+ if (!connectors && !actions) return false;
43
44
  if (actions && req.method === "GET" && req.path === `${actionPath}/catalog`) {
44
45
  ctx.operation = "action:catalog";
45
46
  if (!req.ctx.user) {
@@ -104,7 +105,7 @@ var actionStage = async (ctx) => {
104
105
  });
105
106
  return true;
106
107
  }
107
- if (addressesAction || !hasConnectors) {
108
+ if (addressesAction || !connectors) {
108
109
  ctx.operation = "action";
109
110
  ctx.res.setStatus(404);
110
111
  ctx.res.setHeader("Content-Type", "application/json; charset=utf-8");
@@ -115,7 +116,7 @@ var actionStage = async (ctx) => {
115
116
  return true;
116
117
  }
117
118
  ctx.operation = "action";
118
- await handleAction(req, ctx.res, config, config.connectors);
119
+ await handleAction(req, ctx.res, config, connectorLookupsOf(connectors));
119
120
  return true;
120
121
  };
121
122
  //#endregion
@@ -1,8 +1,8 @@
1
1
  import { SSRContext, Stage } from '../http/types';
2
2
  /**
3
- * The space's functions answering HTTP under `/api/`: a request there that a declared route answers is run
3
+ * The space's functions answering HTTP under `/fn/`: a request there that a declared route answers is run
4
4
  * by it — the deployment's own routes first, then the space's, in the sandbox — and anything else goes on to the page
5
- * server, which answers as it would have. `/api` is never a page: its slug is refused, so the path is the functions'.
5
+ * server, which answers as it would have. `/fn` is never a page: its slug is refused, so the path is the functions'.
6
6
  *
7
7
  * After the auth chain, so `ctx.user` is who the session says; the session itself never reaches the code.
8
8
  */
@@ -2,9 +2,9 @@ import { clientIp, readRawBytes, requestOrigin } from "../requestParser.js";
2
2
  import { FUNCTION_ROUTES_PREFIX } from "@plitzi/sdk-shared/actions";
3
3
  //#region src/core/services/functionRoutes.ts
4
4
  /**
5
- * The space's functions answering HTTP under `/api/`: a request there that a declared route answers is run
5
+ * The space's functions answering HTTP under `/fn/`: a request there that a declared route answers is run
6
6
  * by it — the deployment's own routes first, then the space's, in the sandbox — and anything else goes on to the page
7
- * server, which answers as it would have. `/api` is never a page: its slug is refused, so the path is the functions'.
7
+ * server, which answers as it would have. `/fn` is never a page: its slug is refused, so the path is the functions'.
8
8
  *
9
9
  * After the auth chain, so `ctx.user` is who the session says; the session itself never reaches the code.
10
10
  */
@@ -2,6 +2,7 @@ import { actionStage } from "./action.js";
2
2
  import { functionRoutesStage } from "./functionRoutes.js";
3
3
  import { realtimeStage } from "./realtime.js";
4
4
  import { rscStage } from "./rsc.js";
5
+ import { imagesStage } from "../http/stages/images.js";
5
6
  import { notFoundStage, ssrStage } from "./ssr.js";
6
7
  import { authRoutesStages } from "../http/stages/authRoutes.js";
7
8
  import { fontAssetsStage } from "../http/stages/fontAssets.js";
@@ -17,6 +18,7 @@ var buildPagePipeline = (services, extensions = {}) => {
17
18
  wellKnownStage,
18
19
  pluginAssetsStage,
19
20
  fontAssetsStage,
21
+ imagesStage,
20
22
  ...authRoutesStages,
21
23
  configStaticStage,
22
24
  sdkAssetsStage
@@ -118,7 +118,7 @@ export declare type FunctionFetchInit = {
118
118
  };
119
119
 
120
120
  /**
121
- * An HTTP endpoint of the space, under `/api/`: a web-standard handler, the shape every edge runtime runs. The request
121
+ * An HTTP endpoint of the space, under `/fn/`: a web-standard handler, the shape every edge runtime runs. The request
122
122
  * carries no credentials — `ctx.user` says who is asking — and the response sets no cookies: the host's are the
123
123
  * platform's.
124
124
  */
@@ -136,8 +136,10 @@ export declare type FunctionsDefinition = {
136
136
  hosts?: string[];
137
137
  };
138
138
  tasks?: FunctionTask<never>[];
139
- /** `'GET /board-assets/:board/:asset'` → a handler, served at `/api/board-assets/…`. */
139
+ /** `'GET /board-assets/:board/:asset'` → a handler, served at `/fn/board-assets/…`. */
140
140
  routes?: Record<string, FunctionRoute>;
141
+ /** More CPU or time than an invocation gets by default, for every task and route — a task may ask for its own. */
142
+ limits?: FunctionTimeLimits;
141
143
  };
142
144
 
143
145
  /**
@@ -151,9 +153,28 @@ export declare type FunctionTask<T extends Record<string, unknown> = Record<stri
151
153
  title: string;
152
154
  description?: string;
153
155
  params: Record<keyof T, InteractionCallbackParam<T>>;
156
+ /**
157
+ * More CPU or time than an invocation gets by default — `{ cpuMs: 1000 }` for a task that reshapes a big feed — up to
158
+ * what the server allows. Over the functions' own `limits`, for this task.
159
+ */
160
+ limits?: FunctionTimeLimits;
154
161
  run: (params: T, ctx: FunctionContext) => unknown;
155
162
  };
156
163
 
164
+ /**
165
+ * A space's own functions, as everything outside the runner sees them: the builder, the CLI, the MCP and the
166
+ * platform that stores them. The contract the code itself is written against is `@plitzi/sdk-server/functions`.
167
+ */
168
+ /**
169
+ * What a task — or every task and route, declared once for all of them — asks to be given beyond an invocation's
170
+ * default: CPU time, awaits not counted, and wall time, from the moment it starts. Never more than the server allows;
171
+ * asking for more is a problem when the functions are saved.
172
+ */
173
+ declare type FunctionTimeLimits = {
174
+ cpuMs?: number;
175
+ wallMs?: number;
176
+ };
177
+
157
178
  /**
158
179
  * Who started the run, as a space's own code may know them: everything a function decides by — who, their roles and
159
180
  * permissions — and never their session. A token inside the sandbox is a token a function could send anywhere.
@@ -6,7 +6,7 @@
6
6
  export { ActionRefusal, defineFunctions } from './modules/functions/contract';
7
7
  export type { FunctionContext, FunctionFetch, FunctionFetchInit, FunctionRoute, FunctionRouteContext, FunctionsDefinition, FunctionTask, FunctionUser } from './modules/functions/contract';
8
8
  export type { FunctionsConfig, FunctionUsageRecord } from './modules/functions/config';
9
- export { DEFAULT_FUNCTION_LIMITS } from './modules/functions/config';
9
+ export { DEFAULT_FUNCTION_CEILINGS, DEFAULT_FUNCTION_LIMITS } from './modules/functions/config';
10
10
  /**
11
11
  * What the platform keeps of a space's functions, and what it hands a runner: the built bundle and what it declared
12
12
  * when it was saved (`prepareFunctions` on the actions module is the one way to make one).
package/dist/functions.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ActionRefusal } from "./modules/actions/runtime/errors.js";
2
- import { DEFAULT_FUNCTION_LIMITS } from "./modules/functions/config.js";
2
+ import { DEFAULT_FUNCTION_CEILINGS, DEFAULT_FUNCTION_LIMITS } from "./modules/functions/config.js";
3
3
  import { defineFunctions } from "./modules/functions/contract.js";
4
4
  import { TRY_STEP, functionTryEntry } from "./modules/functions/tryEntry.js";
5
- export { ActionRefusal, DEFAULT_FUNCTION_LIMITS, TRY_STEP, defineFunctions, functionTryEntry };
5
+ export { ActionRefusal, DEFAULT_FUNCTION_CEILINGS, DEFAULT_FUNCTION_LIMITS, TRY_STEP, defineFunctions, functionTryEntry };
@@ -1,3 +1,14 @@
1
+ /** What a cache key reads off a request: where it is, and — from the resolved deployment — what the flags are at. */
2
+ type CacheKeyRequest = {
3
+ hostname: string;
4
+ path: string;
5
+ search: string;
6
+ ctx?: {
7
+ spaceDeployment?: {
8
+ flagsVersion?: string;
9
+ };
10
+ };
11
+ };
1
12
  /**
2
13
  * The key a rendered document is cached under.
3
14
  *
@@ -14,13 +25,17 @@
14
25
  * values, so a page drawn with one visitor's toolbar must not be served to the next. Keyed by its raw value — this
15
26
  * key does not know which keys a space declares, and a space that declares none has no such cookie to split on.
16
27
  *
28
+ * So are the feature flags a tester forced from the dev tools, on a page that authorizes debugging: drawn with them,
29
+ * the page is not everybody's, and a tester holding the cookie must never be handed a page drawn without it. Keyed
30
+ * by its raw value, like the kept state.
31
+ *
32
+ * So is what the space's flags are at (`flagsVersion` on the resolved deployment), for a deployment that changes them
33
+ * apart from its revisions: a flag turned in production must reach the next visitor, while the revision is the same.
34
+ *
17
35
  * Read from the request here rather than handed in, so a call site cannot key the page without it. Only those
18
36
  * cookies are read: keying the whole header would split the cache on every analytics cookie a visitor carries.
19
37
  */
20
- export declare const buildHtmlCacheKey: (accessToken: string | undefined, spaceId: number | string | null, environment: string, revision: number, req: {
21
- hostname: string;
22
- path: string;
23
- search: string;
38
+ export declare const buildHtmlCacheKey: (accessToken: string | undefined, spaceId: number | string | null, environment: string, revision: number, req: CacheKeyRequest & {
24
39
  headers: {
25
40
  cookie?: string;
26
41
  host?: string;
@@ -36,9 +51,7 @@ export declare const readHtmlCacheKey: (key: string) => {
36
51
  environment: string;
37
52
  hostname: string;
38
53
  };
39
- export declare const buildOfflineDataCacheKey: (spaceId: number, environment: string, revision: number) => string;
40
- export declare const buildRscCacheKey: (spaceId: number, environment: string, revision: number, userId: string | number | undefined, idsParam: string | undefined, req: {
41
- hostname: string;
42
- path: string;
43
- search: string;
44
- }) => string;
54
+ /** The space as read for a render: its revision, and what its flags are at when they change apart from it. */
55
+ export declare const buildOfflineDataCacheKey: (spaceId: number, environment: string, revision: number, flagsVersion: string | undefined) => string;
56
+ export declare const buildRscCacheKey: (spaceId: number, environment: string, revision: number, userId: string | number | undefined, idsParam: string | undefined, req: CacheKeyRequest) => string;
57
+ export {};
@@ -1,17 +1,21 @@
1
1
  import { ssrPaintedCookieName } from "../paintedCookie.js";
2
2
  import { readCookie } from "../readCookie.js";
3
+ import { flagsCookieName } from "@plitzi/sdk-shared/flags";
3
4
  import { debugCookieName } from "@plitzi/sdk-shared/devTools";
4
5
  import { themeFromCookies } from "@plitzi/sdk-shared/theme";
5
6
  //#region src/helpers/cache/keys.ts
7
+ var flagsVersionOf = (req) => req.ctx?.spaceDeployment?.flagsVersion ?? "";
6
8
  /** The fields of an HTML key, in the order they are joined. */
7
9
  var HTML_KEY_FIELDS = [
8
10
  "accessToken",
9
11
  "spaceId",
10
12
  "environment",
11
13
  "revision",
14
+ "flags",
12
15
  "theme",
13
16
  "painted",
14
17
  "debugHidden",
18
+ "forcedFlags",
15
19
  "hostname",
16
20
  "path",
17
21
  "search"
@@ -32,6 +36,13 @@ var HTML_KEY_FIELDS = [
32
36
  * values, so a page drawn with one visitor's toolbar must not be served to the next. Keyed by its raw value — this
33
37
  * key does not know which keys a space declares, and a space that declares none has no such cookie to split on.
34
38
  *
39
+ * So are the feature flags a tester forced from the dev tools, on a page that authorizes debugging: drawn with them,
40
+ * the page is not everybody's, and a tester holding the cookie must never be handed a page drawn without it. Keyed
41
+ * by its raw value, like the kept state.
42
+ *
43
+ * So is what the space's flags are at (`flagsVersion` on the resolved deployment), for a deployment that changes them
44
+ * apart from its revisions: a flag turned in production must reach the next visitor, while the revision is the same.
45
+ *
35
46
  * Read from the request here rather than handed in, so a call site cannot key the page without it. Only those
36
47
  * cookies are read: keying the whole header would split the cache on every analytics cookie a visitor carries.
37
48
  */
@@ -41,9 +52,11 @@ var buildHtmlCacheKey = (accessToken = "anonymous", spaceId, environment, revisi
41
52
  spaceId: String(spaceId ?? 1),
42
53
  environment,
43
54
  revision: String(revision),
55
+ flags: flagsVersionOf(req),
44
56
  theme: themeFromCookies(req.headers.cookie) ?? "",
45
57
  painted: readCookie(req.headers.cookie, ssrPaintedCookieName(req.headers.host)) ?? "",
46
58
  debugHidden: readCookie(req.headers.cookie, debugCookieName(req.headers.host)) === "false" ? "debug-off" : "",
59
+ forcedFlags: readCookie(req.headers.cookie, flagsCookieName(req.headers.host)) ?? "",
47
60
  hostname: req.hostname,
48
61
  path: req.path,
49
62
  search: req.search
@@ -64,7 +77,8 @@ var readHtmlCacheKey = (key) => {
64
77
  hostname: at("hostname")
65
78
  };
66
79
  };
67
- var buildOfflineDataCacheKey = (spaceId, environment, revision) => `${spaceId}|${environment}|${revision}`;
68
- var buildRscCacheKey = (spaceId, environment, revision, userId, idsParam, req) => `${spaceId}\0${environment}\0${revision}\0${userId ?? "anon"}\0${idsParam ?? ""}\0${req.hostname}\0${req.path}\0${req.search}`;
80
+ /** The space as read for a render: its revision, and what its flags are at when they change apart from it. */
81
+ var buildOfflineDataCacheKey = (spaceId, environment, revision, flagsVersion) => `${spaceId}|${environment}|${revision}|${flagsVersion ?? ""}`;
82
+ var buildRscCacheKey = (spaceId, environment, revision, userId, idsParam, req) => `${spaceId}\0${environment}\0${revision}\0${flagsVersionOf(req)}\0${userId ?? "anon"}\0${idsParam ?? ""}\0${req.hostname}\0${req.path}\0${req.search}`;
69
83
  //#endregion
70
84
  export { buildHtmlCacheKey, buildOfflineDataCacheKey, buildRscCacheKey, readHtmlCacheKey };
@@ -0,0 +1,27 @@
1
+ import { Environment, FlagOverrides, SSRServerConfig } from '@plitzi/sdk-shared';
2
+ /** The deployment's own layer for one space: `config.flags`, asked per space when it is a function. */
3
+ export declare const serverFlagsFor: (config: Pick<SSRServerConfig, "flags">, space: {
4
+ spaceId: number;
5
+ environment: Environment;
6
+ }) => Record<string, boolean> | undefined;
7
+ /**
8
+ * The flags a tester forced from the dev tools, for a request allowed to debug — undefined for everybody else, and for
9
+ * a tester who forced nothing. Authorization is asked only when there is a cookie to honour, because answering it can
10
+ * mean reading the space.
11
+ */
12
+ export declare const forcedFlagsFor: (req: {
13
+ headers: {
14
+ cookie?: string;
15
+ host?: string;
16
+ };
17
+ }, authorized: boolean | (() => Promise<boolean>)) => Promise<Record<string, boolean> | undefined>;
18
+ /** Both layers this server answers for, for one request. */
19
+ export declare const requestFlagOverrides: (config: Pick<SSRServerConfig, "flags">, req: {
20
+ headers: {
21
+ cookie?: string;
22
+ host?: string;
23
+ };
24
+ }, space: {
25
+ spaceId: number;
26
+ environment: Environment;
27
+ }, authorized: boolean | (() => Promise<boolean>)) => Promise<FlagOverrides>;
@@ -0,0 +1,25 @@
1
+ import { forcedFlagsFromCookies } from "@plitzi/sdk-shared/flags";
2
+ //#region src/helpers/flagOverrides.ts
3
+ /** The deployment's own layer for one space: `config.flags`, asked per space when it is a function. */
4
+ var serverFlagsFor = (config, space) => typeof config.flags === "function" ? config.flags(space) : config.flags;
5
+ /**
6
+ * The flags a tester forced from the dev tools, for a request allowed to debug — undefined for everybody else, and for
7
+ * a tester who forced nothing. Authorization is asked only when there is a cookie to honour, because answering it can
8
+ * mean reading the space.
9
+ */
10
+ var forcedFlagsFor = async (req, authorized) => {
11
+ const forced = forcedFlagsFromCookies(req.headers.cookie, req.headers.host);
12
+ if (Object.keys(forced).length === 0) return;
13
+ return (typeof authorized === "boolean" ? authorized : await authorized()) ? forced : void 0;
14
+ };
15
+ /** Both layers this server answers for, for one request. */
16
+ var requestFlagOverrides = async (config, req, space, authorized) => {
17
+ const server = serverFlagsFor(config, space);
18
+ const qa = await forcedFlagsFor(req, authorized);
19
+ return {
20
+ ...server ? { server } : {},
21
+ ...qa ? { qa } : {}
22
+ };
23
+ };
24
+ //#endregion
25
+ export { forcedFlagsFor, requestFlagOverrides, serverFlagsFor };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createServer, resolveServices } from './core/createServer';
2
2
  export { closeOnSignals } from './core/server/closeOnSignals';
3
3
  export { loadFunctions, readFunctionsDir } from './modules/functions/load';
4
+ export { freePort } from './core/freePort';
4
5
  export { registerHealthCheck, buildHealthPayload } from './core/health';
5
6
  export { consoleLogger, isLogged, logLevelOf, renderLogEvent, serverLog } from './helpers/serverLog';
6
7
  export type { HealthCheckApp, HealthIdentity } from './core/health';
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { buildHealthPayload, registerHealthCheck } from "./core/health.js";
10
10
  import { createServer } from "./core/createServer.js";
11
11
  import { closeOnSignals } from "./core/server/closeOnSignals.js";
12
12
  import { loadFunctions, readFunctionsDir } from "./modules/functions/load.js";
13
+ import { freePort } from "./core/freePort.js";
13
14
  import { createCloudAdapters } from "./adapters/cloudAdapters.js";
14
15
  import { createJsonAdapters } from "./adapters/jsonAdapters.js";
15
16
  import { createAuthAdapters } from "./adapters/authAdapters.js";
@@ -18,4 +19,4 @@ import { authoringPreview, fixedSpace, presentsCredential, verifiedDomain, wildc
18
19
  import { createSpaceAdapters } from "./adapters/space/index.js";
19
20
  import { createLocalFontStore, hostedPathsOf } from "./core/fontStore.js";
20
21
  import { createRedisPubSub } from "./modules/realtime/redisPubSub.js";
21
- export { PREVIEW_TOKEN_PARAM, authoringPreview, buildHealthPayload, closeOnSignals, consoleLogger, createAuthAdapters, createCloudAdapters, createConnectorResolver, createJsonAdapters, createLocalFontStore, createMemoryCache, createMemoryDraftStore, createMemoryPubSub, createRedisPubSub, createServer, createSpaceAdapters, fetchConnectorRecords, fixedSpace, hostedPathsOf, isLogged, loadFunctions, logLevelOf, presentsCredential, readFunctionsDir, rebaseMedia, refuse, registerHealthCheck, renderLogEvent, resolveRscData, resolveServices, serverLog, takeDraftOverride, verifiedDomain, wildcardSubdomain, writeConnectorRecord };
22
+ export { PREVIEW_TOKEN_PARAM, authoringPreview, buildHealthPayload, closeOnSignals, consoleLogger, createAuthAdapters, createCloudAdapters, createConnectorResolver, createJsonAdapters, createLocalFontStore, createMemoryCache, createMemoryDraftStore, createMemoryPubSub, createRedisPubSub, createServer, createSpaceAdapters, fetchConnectorRecords, fixedSpace, freePort, hostedPathsOf, isLogged, loadFunctions, logLevelOf, presentsCredential, readFunctionsDir, rebaseMedia, refuse, registerHealthCheck, renderLogEvent, resolveRscData, resolveServices, serverLog, takeDraftOverride, verifiedDomain, wildcardSubdomain, writeConnectorRecord };
@@ -66,4 +66,4 @@ export type { ActionJobs, JobWorker, JobWorkerOptions, Scheduler, SchedulerOptio
66
66
  export type { ActiveRun, RunGuards } from './runtime/guards';
67
67
  export type { ActionTaskDescriptor } from './taskCatalog';
68
68
  export type { TaskRegistryOptions } from './tasks/registry';
69
- export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionJobsConfig, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
69
+ export type { ActionCredential, ActionDbDriver, ActionEmailConfig, ActionEmailDelivery, ActionEmailMessage, ActionEmailTransport, ActionJobsConfig, ActionKvStore, ActionLookups, ActionRejectRecord, ActionRunRecord, ActionRunRequest, ActionRunResult, ActionsConfig, ActionTask, ActionTaskContext, ActionTaskOrigin, ActionTaskRegistry, RegisteredTask, ResolvedConnector } from './types';
@@ -12,6 +12,7 @@ import { namespaceKv, spaceKvPatterns } from "./runtime/namespaceKv.js";
12
12
  import "./runtime/precheck.js";
13
13
  import { createActionRunner } from "./runtime/runAction.js";
14
14
  import { createTaskRegistry } from "./tasks/registry.js";
15
+ import { functionCeilings } from "../functions/config.js";
15
16
  import { readManifest } from "../functions/manifest.js";
16
17
  import { createRoutes } from "../functions/routes.js";
17
18
  import { createSpaceRegistries, prepareFunctions } from "../functions/space.js";
@@ -72,12 +73,12 @@ var createActionsModule = (given) => {
72
73
  routeFor: routes.routeFor,
73
74
  prepareFunctions: (source) => {
74
75
  const runner = config.functions?.runner;
75
- return runner ? prepareFunctions(source, runner, reserved) : Promise.resolve({
76
+ return runner ? prepareFunctions(source, runner, reserved, functionCeilings(config.functions?.limits)) : Promise.resolve({
76
77
  ok: false,
77
78
  problems: [{ message: "This server runs no space functions" }]
78
79
  });
79
80
  },
80
- readManifest: (declared) => readManifest(declared, reserved),
81
+ readManifest: (declared) => readManifest(declared, reserved, functionCeilings(config.functions?.limits)),
81
82
  guards,
82
83
  kv: (spaceId) => namespaceKv(kv, spaceId),
83
84
  limitsFor
@@ -1,3 +1,4 @@
1
+ import { actionLookupsOf, dbDriversOf, functionsConfigOf } from "../../core/configSeam.js";
1
2
  import { createActionsModule } from "./index.js";
2
3
  import { realtimeModuleFor } from "../realtime/index.js";
3
4
  //#region src/modules/actions/moduleFor.ts
@@ -19,8 +20,9 @@ var actionsModuleFor = (config) => {
19
20
  const existing = modules.get(config);
20
21
  if (existing) return existing;
21
22
  const module = createActionsModule({
22
- lookups,
23
- functions: config.functions,
23
+ lookups: actionLookupsOf(lookups),
24
+ flags: config.flags,
25
+ functions: functionsConfigOf(config.functions),
24
26
  limits: config.action?.limits,
25
27
  concurrency: config.action?.concurrency,
26
28
  kv: config.action?.kv,
@@ -28,7 +30,7 @@ var actionsModuleFor = (config) => {
28
30
  rateLimit: config.action?.rateLimit,
29
31
  idempotency: config.action?.idempotency,
30
32
  signingSecret: config.action?.signingSecret,
31
- dbDrivers: config.action?.dbDrivers,
33
+ dbDrivers: dbDriversOf(config.action?.dbDrivers),
32
34
  email: config.action?.email,
33
35
  onRun: config.action?.onRun,
34
36
  realtime: realtimeModuleFor(config)
@@ -0,0 +1,16 @@
1
+ import { ActionRunRequest, ActionsConfig } from '../types';
2
+ /**
3
+ * Whether a flow names `flags` anywhere — a template, a `when` rule's field. Only those are worth a lookup: most flows
4
+ * read no flag, and asking for the space's flags on every run of every one of them would be a read per click.
5
+ *
6
+ * Over the serialized document, so a template in any step's params counts without knowing every step's shape. A word
7
+ * `flags` that is not the source costs one lookup that was not needed, never a flow without its flags. Kept per
8
+ * document, which the lookups hand out once per version.
9
+ */
10
+ export declare const readsFlags: (document: object) => boolean;
11
+ /**
12
+ * The space's flags as this run sees them: what the space declares at the run's revision, matched against the run's
13
+ * environment and visitor, then the server's own layer and a tester's forced one — the same order a page resolves
14
+ * them in. A run has no URL, so a rule on the host or the route does not match here.
15
+ */
16
+ export declare const flagsForRun: (config: Pick<ActionsConfig, "lookups" | "flags">, request: Pick<ActionRunRequest, "entry" | "spaceId" | "environment" | "at" | "user" | "forcedFlags">) => Promise<Record<string, boolean>>;
@@ -0,0 +1,49 @@
1
+ import { serverLog } from "../../../helpers/serverLog.js";
2
+ import { serverFlagsFor } from "../../../helpers/flagOverrides.js";
3
+ import { flagUserFromSSR, flagValues, resolveFlags, undeclaredFlagOverrides } from "@plitzi/sdk-shared/flags";
4
+ //#region src/modules/actions/runtime/flags.ts
5
+ var reading = /* @__PURE__ */ new WeakMap();
6
+ /**
7
+ * Whether a flow names `flags` anywhere — a template, a `when` rule's field. Only those are worth a lookup: most flows
8
+ * read no flag, and asking for the space's flags on every run of every one of them would be a read per click.
9
+ *
10
+ * Over the serialized document, so a template in any step's params counts without knowing every step's shape. A word
11
+ * `flags` that is not the source costs one lookup that was not needed, never a flow without its flags. Kept per
12
+ * document, which the lookups hand out once per version.
13
+ */
14
+ var readsFlags = (document) => {
15
+ const known = reading.get(document);
16
+ if (known !== void 0) return known;
17
+ const reads = /\bflags\b/.test(JSON.stringify(document));
18
+ reading.set(document, reads);
19
+ return reads;
20
+ };
21
+ /**
22
+ * The space's flags as this run sees them: what the space declares at the run's revision, matched against the run's
23
+ * environment and visitor, then the server's own layer and a tester's forced one — the same order a page resolves
24
+ * them in. A run has no URL, so a rule on the host or the route does not match here.
25
+ */
26
+ var flagsForRun = async (config, request) => {
27
+ if (!readsFlags(request.entry.document)) return {};
28
+ const { getFlags } = config.lookups;
29
+ if (!getFlags) {
30
+ serverLog.warn("Actions", `Action "${request.entry.id}" reads flags, but this deployment's action lookups have no getFlags: it sees none.`);
31
+ return {};
32
+ }
33
+ const declared = await getFlags(request.spaceId, request.at);
34
+ const { spaceId, environment, user } = request;
35
+ const overrides = {
36
+ server: serverFlagsFor(config, {
37
+ spaceId,
38
+ environment
39
+ }),
40
+ qa: request.forcedFlags
41
+ };
42
+ undeclaredFlagOverrides(declared, { server: overrides.server }).forEach(({ name }) => serverLog.warn("Actions", `The server sets the flag "${name}", which space ${spaceId} does not declare.`));
43
+ return flagValues(resolveFlags(declared, {
44
+ environment,
45
+ user: flagUserFromSSR(user)
46
+ }, overrides));
47
+ };
48
+ //#endregion
49
+ export { flagsForRun, readsFlags };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "@plitzi/sdk-shared/helpers/isRecord";
1
2
  //#region src/modules/actions/runtime/kvList.ts
2
3
  /**
3
4
  * The most a list holds. A list is one value of the store, read and written whole, so it is for what a page shows at
@@ -11,7 +12,6 @@ var MAX_LIST_ENTRIES = 500;
11
12
  var MAX_LIST_BYTES = 128e3;
12
13
  var ID = /^[A-Za-z0-9:_.@-]{1,128}$/;
13
14
  var listIdProblem = (id) => typeof id === "string" && ID.test(id) ? void 0 : "A list entry’s id is 1-128 of A-Z a-z 0-9 : _ . @ -";
14
- var isRecord = (value) => typeof value === "object" && value !== null;
15
15
  var isEntry = (value) => isRecord(value) && typeof value.id === "string" && typeof value.score === "number";
16
16
  /** The entries a stored list holds — none for a key that holds nothing, or something that is not a list. */
17
17
  var parseList = (raw) => {
@@ -72,6 +72,7 @@ var createActionResolver = (lookups, module) => {
72
72
  values
73
73
  ]);
74
74
  const callerId = user ? `user:${user.id}` : "render";
75
+ let ownRunId;
75
76
  const startRun = async () => {
76
77
  /**
77
78
  * A key of its own per render, so two visitors are never each other's duplicate.
@@ -90,6 +91,7 @@ var createActionResolver = (lookups, module) => {
90
91
  kind: "render",
91
92
  ttlMs: module.limitsFor(entry.document).timeoutMs
92
93
  });
94
+ ownRunId = run.runId;
93
95
  /**
94
96
  * The render giving up ends the run, not just the wait for it.
95
97
  *
@@ -157,13 +159,35 @@ var createActionResolver = (lookups, module) => {
157
159
  await module.guards.end(run);
158
160
  }
159
161
  };
162
+ let told = false;
160
163
  const record = (summary) => {
161
- (req.ctx.actionRuns ??= []).push({
162
- ...summary,
163
- elementId: element.id
164
- });
164
+ if (!told) {
165
+ told = true;
166
+ (req.ctx.actionRuns ??= []).push({
167
+ ...summary,
168
+ elementId: element.id
169
+ });
170
+ }
165
171
  };
166
172
  /**
173
+ * The page stopped waiting — the section's budget ran out — and is answered without it: said NOW, while the page
174
+ * still has the request's runs to send. The run itself ends a moment later, aborted, when the page has gone; told
175
+ * then, a debugger would never hear of the one run it needed to — the slow one.
176
+ */
177
+ const askedAt = Date.now();
178
+ const stopWaiting = onAbort(signal, () => {
179
+ record({
180
+ actionId: entry.id,
181
+ runId: ownRunId ?? `render:${randomUUID()}`,
182
+ trigger: "render",
183
+ status: "aborted",
184
+ startedAt: askedAt,
185
+ endedAt: Date.now(),
186
+ steps: [],
187
+ error: "Still running when the page stopped waiting for it: it took longer than a section is given"
188
+ });
189
+ });
190
+ /**
167
191
  * Everyone asking the same question at once gets one run, and its answer.
168
192
  *
169
193
  * Not an optimisation — it is what makes a page survive being read. Without it, a thousand visitors of one URL
@@ -177,6 +201,8 @@ var createActionResolver = (lookups, module) => {
177
201
  } catch (error) {
178
202
  if (error instanceof RenderRunFailure) record(error.summary);
179
203
  throw error;
204
+ } finally {
205
+ stopWaiting();
180
206
  }
181
207
  };
182
208
  };
@@ -5,6 +5,7 @@ import { createKvStore } from "./kvStore.js";
5
5
  import { resolveLimits } from "./limits.js";
6
6
  import { createMemoryKv } from "./memoryKv.js";
7
7
  import { createEmailSender } from "./email.js";
8
+ import { flagsForRun } from "./flags.js";
8
9
  import { createRedactor, projectUser } from "./scope.js";
9
10
  import { precheckRun } from "./precheck.js";
10
11
  import { createSigning } from "./signing.js";
@@ -240,6 +241,7 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
240
241
  const scope = {
241
242
  input: values,
242
243
  user: projectUser(request.user),
244
+ flags: await flagsForRun(config, request),
243
245
  spaceId: request.spaceId,
244
246
  environment: request.environment,
245
247
  trigger: request.trigger,