@plitzi/sdk-server 0.37.8 → 0.37.9

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 (38) hide show
  1. package/CHANGELOG.md +236 -0
  2. package/README.md +7 -2
  3. package/dist/actions.d.ts +1 -1
  4. package/dist/adapters/cloudAdapters.js +4 -30
  5. package/dist/core/services/functionRoutes.d.ts +2 -2
  6. package/dist/core/services/functionRoutes.js +2 -2
  7. package/dist/functions-api.d.ts +23 -2
  8. package/dist/functions.d.ts +1 -1
  9. package/dist/functions.js +2 -2
  10. package/dist/modules/actions/index.d.ts +1 -1
  11. package/dist/modules/actions/index.js +3 -2
  12. package/dist/modules/actions/runtime/renderResolver.js +30 -4
  13. package/dist/modules/actions/taskCatalog.d.ts +3 -1
  14. package/dist/modules/actions/taskCatalog.js +1 -0
  15. package/dist/modules/actions/tasks/registry.js +4 -2
  16. package/dist/modules/actions/types.d.ts +6 -0
  17. package/dist/modules/functions/config.d.ts +19 -3
  18. package/dist/modules/functions/config.js +30 -14
  19. package/dist/modules/functions/contract.d.ts +10 -3
  20. package/dist/modules/functions/driver.js +4 -2
  21. package/dist/modules/functions/manifest.d.ts +5 -3
  22. package/dist/modules/functions/manifest.js +36 -5
  23. package/dist/modules/functions/routes.d.ts +1 -1
  24. package/dist/modules/functions/routes.js +3 -3
  25. package/dist/modules/functions/space.d.ts +7 -5
  26. package/dist/modules/functions/space.js +17 -10
  27. package/dist/modules/runtime/contract.d.ts +1 -1
  28. package/dist/modules/runtime/stages.d.ts +1 -1
  29. package/dist/modules/runtime/stages.js +1 -1
  30. package/dist/modules/ssr/prepareRender.js +2 -2
  31. package/dist/modules/ssr/registerExternalPlugins.d.ts +5 -1
  32. package/dist/modules/ssr/registerExternalPlugins.js +6 -2
  33. package/package.json +3 -3
  34. package/skills/plitzi-authoring/SKILL.md +11 -8
  35. package/skills/plitzi-authoring/reference/components.md +75 -0
  36. package/skills/plitzi-authoring/reference/flows.md +1 -1
  37. package/skills/plitzi-authoring/reference/plugins.md +12 -0
  38. package/skills/plitzi-authoring/reference/templates-and-export.md +6 -0
@@ -1,24 +1,40 @@
1
+ import { DEFAULT_FUNCTION_TIME_LIMITS } from "@plitzi/sdk-shared/actions";
1
2
  //#region src/modules/functions/config.ts
3
+ /** What an invocation gets unless its task asks for more CPU or time. */
2
4
  var DEFAULT_FUNCTION_LIMITS = {
3
- cpuMs: 100,
4
- wallMs: 1e4,
5
+ ...DEFAULT_FUNCTION_TIME_LIMITS,
5
6
  memoryMb: 64,
6
7
  outputBytes: 1e6,
7
8
  calls: 100
8
9
  };
9
- /** What one invocation of a space may spend: its own limits, never above the deployment's. */
10
- var functionLimitsFor = (ceilings = {}, space = {}) => {
11
- const ceiling = {
12
- ...DEFAULT_FUNCTION_LIMITS,
13
- ...ceilings
14
- };
10
+ /**
11
+ * The most a deployment lets one invocation have, unless it says otherwise: two seconds of CPU and half a minute of
12
+ * time for a task that asks; the rest as every invocation gets it.
13
+ */
14
+ var DEFAULT_FUNCTION_CEILINGS = {
15
+ ...DEFAULT_FUNCTION_LIMITS,
16
+ cpuMs: 2e3,
17
+ wallMs: 3e4
18
+ };
19
+ /** A deployment's ceilings, each unset one the default's. */
20
+ var functionCeilings = (ceilings = {}) => ({
21
+ ...DEFAULT_FUNCTION_CEILINGS,
22
+ ...ceilings
23
+ });
24
+ /**
25
+ * What one invocation of a space may spend: the CPU and time its task asked for — the default's when it asked for none
26
+ * — and the rest as the deployment gives it; never above the space's plan, nor above the deployment's ceilings.
27
+ */
28
+ var functionLimitsFor = (ceilings = {}, space = {}, asked = {}) => {
29
+ const ceiling = functionCeilings(ceilings);
30
+ const capOf = (key) => Math.min(space[key] ?? ceiling[key], ceiling[key]);
15
31
  return {
16
- cpuMs: Math.min(space.cpuMs ?? ceiling.cpuMs, ceiling.cpuMs),
17
- wallMs: Math.min(space.wallMs ?? ceiling.wallMs, ceiling.wallMs),
18
- memoryMb: Math.min(space.memoryMb ?? ceiling.memoryMb, ceiling.memoryMb),
19
- outputBytes: Math.min(space.outputBytes ?? ceiling.outputBytes, ceiling.outputBytes),
20
- calls: Math.min(space.calls ?? ceiling.calls, ceiling.calls)
32
+ cpuMs: Math.min(asked.cpuMs ?? DEFAULT_FUNCTION_LIMITS.cpuMs, capOf("cpuMs")),
33
+ wallMs: Math.min(asked.wallMs ?? DEFAULT_FUNCTION_LIMITS.wallMs, capOf("wallMs")),
34
+ memoryMb: capOf("memoryMb"),
35
+ outputBytes: capOf("outputBytes"),
36
+ calls: capOf("calls")
21
37
  };
22
38
  };
23
39
  //#endregion
24
- export { DEFAULT_FUNCTION_LIMITS, functionLimitsFor };
40
+ export { DEFAULT_FUNCTION_CEILINGS, DEFAULT_FUNCTION_LIMITS, functionCeilings, functionLimitsFor };
@@ -1,6 +1,6 @@
1
1
  import { RateCount, RateLimit } from '../actions/runtime/rateLimit';
2
2
  import { ActionKvStore } from '../actions/types';
3
- import { Environment, InteractionCallbackParam } from '@plitzi/sdk-shared';
3
+ import { Environment, FunctionTimeLimits, InteractionCallbackParam } from '@plitzi/sdk-shared';
4
4
  /**
5
5
  * Who started the run, as a space's own code may know them: everything a function decides by — who, their roles and
6
6
  * permissions — and never their session. A token inside the sandbox is a token a function could send anywhere.
@@ -85,6 +85,11 @@ export type FunctionTask<T extends Record<string, unknown> = Record<string, unkn
85
85
  title: string;
86
86
  description?: string;
87
87
  params: Record<keyof T, InteractionCallbackParam<T>>;
88
+ /**
89
+ * More CPU or time than an invocation gets by default — `{ cpuMs: 1000 }` for a task that reshapes a big feed — up to
90
+ * what the server allows. Over the functions' own `limits`, for this task.
91
+ */
92
+ limits?: FunctionTimeLimits;
88
93
  run: (params: T, ctx: FunctionContext) => unknown;
89
94
  };
90
95
  /** What a route's handler gets besides the request: the context, and the `:params` its key named, by name. */
@@ -92,7 +97,7 @@ export type FunctionRouteContext = FunctionContext & {
92
97
  params: Record<string, string>;
93
98
  };
94
99
  /**
95
- * An HTTP endpoint of the space, under `/api/`: a web-standard handler, the shape every edge runtime runs. The request
100
+ * An HTTP endpoint of the space, under `/fn/`: a web-standard handler, the shape every edge runtime runs. The request
96
101
  * carries no credentials — `ctx.user` says who is asking — and the response sets no cookies: the host's are the
97
102
  * platform's.
98
103
  */
@@ -104,8 +109,10 @@ export type FunctionsDefinition = {
104
109
  hosts?: string[];
105
110
  };
106
111
  tasks?: FunctionTask<never>[];
107
- /** `'GET /board-assets/:board/:asset'` → a handler, served at `/api/board-assets/…`. */
112
+ /** `'GET /board-assets/:board/:asset'` → a handler, served at `/fn/board-assets/…`. */
108
113
  routes?: Record<string, FunctionRoute>;
114
+ /** More CPU or time than an invocation gets by default, for every task and route — a task may ask for its own. */
115
+ limits?: FunctionTimeLimits;
109
116
  };
110
117
  /**
111
118
  * What a function throws to refuse, with a reason written for whoever asked — a wrong password, a board that is
@@ -14,9 +14,11 @@ var describeFunctions = (definition) => {
14
14
  action: task.action,
15
15
  title: task.title,
16
16
  description: task.description,
17
- params: task.params
17
+ params: task.params,
18
+ limits: task.limits
18
19
  } : {}),
19
- routes: isRecord(found.routes) ? Object.keys(found.routes) : []
20
+ routes: isRecord(found.routes) ? Object.keys(found.routes) : [],
21
+ limits: found.limits
20
22
  };
21
23
  };
22
24
  /**
@@ -1,3 +1,4 @@
1
+ import { FunctionLimits } from './protocol';
1
2
  import { FunctionsManifest } from '@plitzi/sdk-shared';
2
3
  export declare const ROUTE_METHODS: readonly ["GET", "POST", "PUT", "PATCH", "DELETE"];
3
4
  export type RouteMethod = (typeof ROUTE_METHODS)[number];
@@ -6,7 +7,7 @@ export type RouteKey = {
6
7
  segments: string[];
7
8
  };
8
9
  /**
9
- * A route key, `'<METHOD> /<path>'`: literal segments and `:params`, served under `/api/`. Anything else — a query, a
10
+ * A route key, `'<METHOD> /<path>'`: literal segments and `:params`, served under `/fn/`. Anything else — a query, a
10
11
  * wildcard, `..` — is not a key, so what a space may answer is only ever a path it spelled out.
11
12
  */
12
13
  export declare const parseRouteKey: (key: string) => RouteKey | undefined;
@@ -19,6 +20,7 @@ export type ManifestReading = {
19
20
  * afterwards, because what is stored is only ever what passed it.
20
21
  *
21
22
  * `reserved` is every namespace this deployment's own tasks use: a space's task may take none of them, so a step that
22
- * names a platform task always runs the platform's.
23
+ * names a platform task always runs the platform's. `ceilings` is the most this deployment gives one invocation: what a
24
+ * task may ask for.
23
25
  */
24
- export declare const readManifest: (value: unknown, reserved: ReadonlySet<string>) => ManifestReading;
26
+ export declare const readManifest: (value: unknown, reserved: ReadonlySet<string>, ceilings: FunctionLimits) => ManifestReading;
@@ -10,7 +10,7 @@ var ROUTE_METHODS = [
10
10
  var SEGMENT = /^(:[a-zA-Z][a-zA-Z0-9]*|[A-Za-z0-9._~-]+)$/;
11
11
  var isRouteMethod = (method) => ROUTE_METHODS.some((known) => known === method);
12
12
  /**
13
- * A route key, `'<METHOD> /<path>'`: literal segments and `:params`, served under `/api/`. Anything else — a query, a
13
+ * A route key, `'<METHOD> /<path>'`: literal segments and `:params`, served under `/fn/`. Anything else — a query, a
14
14
  * wildcard, `..` — is not a key, so what a space may answer is only ever a path it spelled out.
15
15
  */
16
16
  var parseRouteKey = (key) => {
@@ -83,14 +83,41 @@ var paramOf = (value) => {
83
83
  default: return `has a type the builder cannot draw ("${String(value.type)}"): text, textarea, codemirror-text, codemirror-json, boolean, elements or select`;
84
84
  }
85
85
  };
86
+ /** What each asked limit is, in words a problem is written in. */
87
+ var LIMIT_WORDS = {
88
+ cpuMs: "of CPU",
89
+ wallMs: "to finish"
90
+ };
91
+ /**
92
+ * What `where` asks for beyond the default, read: whole milliseconds above nothing — and never more than `ceilings`, which
93
+ * is said rather than quietly cut down, so the author knows what the code will get.
94
+ */
95
+ var limitsOf = (value, where, ceilings, problems) => {
96
+ if (value === void 0) return;
97
+ if (!isRecord(value)) {
98
+ problems.push(`${where}: limits is not an object like { cpuMs: 1000, wallMs: 20000 }`);
99
+ return;
100
+ }
101
+ const limits = {};
102
+ ["cpuMs", "wallMs"].forEach((key) => {
103
+ const asked = value[key];
104
+ if (asked === void 0) return;
105
+ if (typeof asked !== "number" || !Number.isInteger(asked) || asked <= 0) problems.push(`${where}: limits.${key} is not a whole number of milliseconds above 0`);
106
+ else if (asked > ceilings[key]) problems.push(`${where}: asks for ${String(asked)} ms ${LIMIT_WORDS[key]}, and this server allows at most ${String(ceilings[key])} ms`);
107
+ else limits[key] = asked;
108
+ });
109
+ Object.keys(value).filter((key) => key !== "cpuMs" && key !== "wallMs").forEach((key) => problems.push(`${where}: limits.${key} is not a limit a function asks for (cpuMs, wallMs)`));
110
+ return Object.keys(limits).length ? limits : void 0;
111
+ };
86
112
  /**
87
113
  * Reads what a bundle said it declares, and every rule it must meet — the one validator, at save and nowhere else
88
114
  * afterwards, because what is stored is only ever what passed it.
89
115
  *
90
116
  * `reserved` is every namespace this deployment's own tasks use: a space's task may take none of them, so a step that
91
- * names a platform task always runs the platform's.
117
+ * names a platform task always runs the platform's. `ceilings` is the most this deployment gives one invocation: what a
118
+ * task may ask for.
92
119
  */
93
- var readManifest = (value, reserved) => {
120
+ var readManifest = (value, reserved, ceilings) => {
94
121
  const problems = [];
95
122
  const raw = isRecord(value) ? value : {};
96
123
  const hosts = Array.isArray(raw.hosts) ? raw.hosts.filter((host) => typeof host === "string") : [];
@@ -130,12 +157,14 @@ var readManifest = (value, reserved) => {
130
157
  else params[key] = read;
131
158
  });
132
159
  const description = stringOf(task.description);
160
+ const limits = limitsOf(task.limits, where, ceilings, problems);
133
161
  tasks.push({
134
162
  namespace,
135
163
  action,
136
164
  title,
137
165
  ...description ? { description } : {},
138
- params
166
+ params,
167
+ ...limits ? { limits } : {}
139
168
  });
140
169
  });
141
170
  const routes = Array.isArray(raw.routes) ? raw.routes.filter((route) => typeof route === "string") : [];
@@ -150,11 +179,13 @@ var readManifest = (value, reserved) => {
150
179
  if (routed.has(shape)) problems.push(`Route "${route}" answers the same requests as another one`);
151
180
  routed.add(shape);
152
181
  });
182
+ const limits = limitsOf(raw.limits, "The functions", ceilings, problems);
153
183
  return {
154
184
  manifest: {
155
185
  hosts,
156
186
  tasks,
157
- routes
187
+ routes,
188
+ ...limits ? { limits } : {}
158
189
  },
159
190
  problems
160
191
  };
@@ -31,6 +31,6 @@ export type RouteSources = {
31
31
  * each run with the same context a task of theirs gets, `trigger: 'route'`.
32
32
  */
33
33
  export declare const createRoutes: ({ config, getFunctions, taskContext }: RouteSources) => {
34
- /** The route that answers `method path` (the path after `/api`), for this visit — or none. */
34
+ /** The route that answers `method path` (the path after `/fn`), for this visit — or none. */
35
35
  routeFor: (visit: RouteVisit, method: string, path: string) => Promise<RouteHandler | undefined>;
36
36
  };
@@ -1,9 +1,9 @@
1
1
  import { serverLog } from "../../helpers/serverLog.js";
2
2
  import { ActionRefusal } from "../actions/runtime/errors.js";
3
3
  import { functionContextFor } from "./context.js";
4
+ import { functionLimitsFor } from "./config.js";
4
5
  import { parseRouteKey } from "./manifest.js";
5
6
  import { answerCall, readCall, responseOf, wireRequestOf, wireResponseFrom } from "./capabilities.js";
6
- import { functionLimitsFor } from "./config.js";
7
7
  import { FunctionFailure } from "./protocol.js";
8
8
  import { randomUUID } from "node:crypto";
9
9
  //#region src/modules/functions/routes.ts
@@ -151,7 +151,7 @@ var createRoutes = ({ config, getFunctions, taskContext }) => {
151
151
  ...fnCtx.user ? { user: fnCtx.user } : {}
152
152
  }
153
153
  },
154
- limits: functionLimitsFor(config.limits, functions.limits),
154
+ limits: functionLimitsFor(config.limits, functions.limits, functions.manifest.limits),
155
155
  answer: (call) => answerCall(fnCtx, readCall(call)),
156
156
  signal: visit.signal,
157
157
  onUsage: (usage) => {
@@ -174,7 +174,7 @@ var createRoutes = ({ config, getFunctions, taskContext }) => {
174
174
  };
175
175
  };
176
176
  return {
177
- /** The route that answers `method path` (the path after `/api`), for this visit — or none. */
177
+ /** The route that answers `method path` (the path after `/fn`), for this visit — or none. */
178
178
  routeFor: async (visit, method, path) => nativeRoute(visit, method, path) ?? await spaceRoute(visit, method, path) };
179
179
  };
180
180
  //#endregion
@@ -2,12 +2,13 @@ import { FunctionsSource } from './build';
2
2
  import { FunctionsConfig } from './config';
3
3
  import { FunctionsBundle, FunctionLimits, FunctionRunner, SpaceFunctions } from './protocol';
4
4
  import { ActionTask, ActionTaskRegistry } from '../actions/types';
5
- import { FunctionsManifest, FunctionsProblem } from '@plitzi/sdk-shared';
5
+ import { FunctionsManifest, FunctionsProblem, FunctionTimeLimits } from '@plitzi/sdk-shared';
6
6
  /**
7
7
  * A space's tasks as the registry holds them — each run by the runner, answering the code's calls with the run's own
8
- * {@link FunctionContext}: the same one native functions get, built by the same function.
8
+ * {@link FunctionContext}: the same one native functions get, built by the same function. Each is given what
9
+ * `limitsFor` answers for what it asked: its own limits, over its functions'.
9
10
  */
10
- export declare const spaceTasks: (functions: SpaceFunctions, runner: FunctionRunner, limits: FunctionLimits, { admit, onUsage }?: Pick<FunctionsConfig, "admit" | "onUsage">) => ActionTask<Record<string, unknown>>[];
11
+ export declare const spaceTasks: (functions: SpaceFunctions, runner: FunctionRunner, limitsFor: (asked: FunctionTimeLimits) => FunctionLimits, { admit, onUsage }?: Pick<FunctionsConfig, "admit" | "onUsage">) => ActionTask<Record<string, unknown>>[];
11
12
  export type SpaceRegistries = {
12
13
  /** The tasks one run of that space can use: the deployment's, and its own functions' when it has any. */
13
14
  registryFor: (functions: SpaceFunctions | undefined) => ActionTaskRegistry;
@@ -35,6 +36,7 @@ export type PreparedFunctions = {
35
36
  };
36
37
  /**
37
38
  * A space's source made ready to save: built, read by the runner, and checked — the only way a space's functions are
38
- * stored, so what is stored always passed every rule. `reserved` is every namespace the deployment's own tasks use.
39
+ * stored, so what is stored always passed every rule. `reserved` is every namespace the deployment's own tasks use;
40
+ * `ceilings`, the most it gives one invocation — what a task may ask for.
39
41
  */
40
- export declare const prepareFunctions: (source: FunctionsSource, runner: FunctionRunner, reserved: ReadonlySet<string>) => Promise<PreparedFunctions>;
42
+ export declare const prepareFunctions: (source: FunctionsSource, runner: FunctionRunner, reserved: ReadonlySet<string>, ceilings: FunctionLimits) => Promise<PreparedFunctions>;
@@ -1,9 +1,9 @@
1
1
  import { ActionRefusal } from "../actions/runtime/errors.js";
2
2
  import { functionContextFor } from "./context.js";
3
3
  import { taskName } from "../actions/tasks/registry.js";
4
+ import { functionLimitsFor } from "./config.js";
4
5
  import { readManifest } from "./manifest.js";
5
6
  import { answerCall, readCall } from "./capabilities.js";
6
- import { functionLimitsFor } from "./config.js";
7
7
  import { FunctionFailure } from "./protocol.js";
8
8
  import { FunctionsBuildError, buildFunctions } from "./build.js";
9
9
  import { createHash } from "node:crypto";
@@ -18,10 +18,15 @@ var invocationContextOf = ({ spaceId, environment, runId, trigger, callerId, use
18
18
  });
19
19
  /**
20
20
  * A space's tasks as the registry holds them — each run by the runner, answering the code's calls with the run's own
21
- * {@link FunctionContext}: the same one native functions get, built by the same function.
21
+ * {@link FunctionContext}: the same one native functions get, built by the same function. Each is given what
22
+ * `limitsFor` answers for what it asked: its own limits, over its functions'.
22
23
  */
23
- var spaceTasks = (functions, runner, limits, { admit, onUsage } = {}) => functions.manifest.tasks.map((task) => {
24
+ var spaceTasks = (functions, runner, limitsFor, { admit, onUsage } = {}) => functions.manifest.tasks.map((task) => {
24
25
  const name = taskName(task);
26
+ const limits = limitsFor({
27
+ ...functions.manifest.limits,
28
+ ...task.limits
29
+ });
25
30
  return {
26
31
  namespace: task.namespace,
27
32
  action: task.action,
@@ -73,7 +78,8 @@ var composeRegistry = (base, tasks) => {
73
78
  const name = taskName(task);
74
79
  if (!base.get(name)) own.set(name, {
75
80
  ...task,
76
- name
81
+ name,
82
+ origin: "space"
77
83
  });
78
84
  });
79
85
  return {
@@ -91,11 +97,11 @@ var createSpaceRegistries = (base, config = {}) => {
91
97
  return { registryFor: (functions) => {
92
98
  const runner = functions?.runner ?? config.runner;
93
99
  if (!functions || !runner) return base;
94
- const limits = functionLimitsFor(config.limits, functions.limits);
95
- const key = `${functions.bundle.id}:${JSON.stringify(limits)}`;
100
+ const key = `${functions.bundle.id}:${JSON.stringify(functions.limits ?? {})}`;
96
101
  const found = cache.get(key);
97
102
  if (found) return found;
98
- const registry = composeRegistry(base, spaceTasks(functions, runner, limits, config));
103
+ const limitsFor = (asked) => functionLimitsFor(config.limits, functions.limits, asked);
104
+ const registry = composeRegistry(base, spaceTasks(functions, runner, limitsFor, config));
99
105
  cache.set(key, registry);
100
106
  if (cache.size > REGISTRY_CACHE) {
101
107
  const oldest = cache.keys().next().value;
@@ -114,9 +120,10 @@ var functionsInHand = ({ bundle, manifest }) => ({
114
120
  });
115
121
  /**
116
122
  * A space's source made ready to save: built, read by the runner, and checked — the only way a space's functions are
117
- * stored, so what is stored always passed every rule. `reserved` is every namespace the deployment's own tasks use.
123
+ * stored, so what is stored always passed every rule. `reserved` is every namespace the deployment's own tasks use;
124
+ * `ceilings`, the most it gives one invocation — what a task may ask for.
118
125
  */
119
- var prepareFunctions = async (source, runner, reserved) => {
126
+ var prepareFunctions = async (source, runner, reserved, ceilings) => {
120
127
  let code;
121
128
  try {
122
129
  ({code} = await buildFunctions(source));
@@ -143,7 +150,7 @@ var prepareFunctions = async (source, runner, reserved) => {
143
150
  }]
144
151
  };
145
152
  }
146
- const { manifest, problems } = readManifest(declared, reserved);
153
+ const { manifest, problems } = readManifest(declared, reserved, ceilings);
147
154
  return problems.length ? {
148
155
  ok: false,
149
156
  problems: problems.map((message) => ({
@@ -14,7 +14,7 @@ export type SpaceRuntimeContext = {
14
14
  export type SpaceRuntimeEndpoint = (request: Request) => Response | Promise<Response>;
15
15
  /** What a started runtime is made of. */
16
16
  export type SpaceRuntimeParts = {
17
- /** Its tasks and `/api/` routes, run with a `ctx` that is the platform's — the space's `kv`, its channels, its key. */
17
+ /** Its tasks and `/fn/` routes, run with a `ctx` that is the platform's — the space's `kv`, its channels, its key. */
18
18
  functions?: FunctionsDefinition;
19
19
  /** Paths it answers itself, each with everything beneath it (`/mcp` answers `/mcp/…`) — see {@link endpointProblem}. */
20
20
  endpoints?: Record<string, SpaceRuntimeEndpoint>;
@@ -45,6 +45,6 @@ export type RuntimeProxyConfig = {
45
45
  * The page server forwarding a space's endpoints to its runtime: the request as it came, streamed both ways — an agent's
46
46
  * event stream included — without the visitor's cookies or `Authorization`, and with who asked in `X-Forwarded-*`.
47
47
  *
48
- * A data stage: after the auth chain, so the space is known, and before the space's `/api/` routes and its pages.
48
+ * A data stage: after the auth chain, so the space is known, and before the space's `/fn/` routes and its pages.
49
49
  */
50
50
  export declare const createRuntimeProxyStage: ({ lookup, onForward }: RuntimeProxyConfig) => Stage<SSRContext>;
@@ -55,7 +55,7 @@ var NOT_ANSWERED = /* @__PURE__ */ new Set([...HOP_BY_HOP, "set-cookie"]);
55
55
  * The page server forwarding a space's endpoints to its runtime: the request as it came, streamed both ways — an agent's
56
56
  * event stream included — without the visitor's cookies or `Authorization`, and with who asked in `X-Forwarded-*`.
57
57
  *
58
- * A data stage: after the auth chain, so the space is known, and before the space's `/api/` routes and its pages.
58
+ * A data stage: after the auth chain, so the space is known, and before the space's `/fn/` routes and its pages.
59
59
  */
60
60
  var createRuntimeProxyStage = ({ lookup, onForward }) => async (ctx) => {
61
61
  const { spaceId, environment = "main" } = ctx.req.ctx.spaceDeployment ?? {};
@@ -143,11 +143,11 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
143
143
  const key = pluginManager.ensure(pluginName, pluginSource);
144
144
  if (!pluginBaseNames.has(pluginName)) dynamicNames.push(key);
145
145
  }
146
- const externalNamesFiltered = (config.autoLoadSchemaPlugins !== false ? await m("extPlugins", () => registerExternalPlugins(pluginManager, offlineData, { allowPrivateHosts: config.allowPrivatePluginHosts === true })) : []).filter((k) => !pluginBaseNames.has(k.replace(/@[^@]*$/, "")));
146
+ const externalNames = config.autoLoadSchemaPlugins !== false ? await m("extPlugins", () => registerExternalPlugins(pluginManager, offlineData, { allowPrivateHosts: config.allowPrivatePluginHosts === true }, pluginBaseNames)) : [];
147
147
  const allPluginNames = [
148
148
  ...pluginNames,
149
149
  ...dynamicNames,
150
- ...externalNamesFiltered
150
+ ...externalNames
151
151
  ];
152
152
  const entries = allPluginNames.length > 0 ? await pluginManager.getEntries(allPluginNames) : [];
153
153
  const pluginComponents = await m("plugins", () => loadPluginComponents(entries, pluginManager.getComponents()));
@@ -7,6 +7,10 @@ type ManifestReach = {
7
7
  /**
8
8
  * Reads plugins listed in offlineData.plugins, fetches their manifests, downloads and caches
9
9
  * JS/CSS via the PluginManager, and returns the effective plugin keys to pass to getEntries().
10
+ *
11
+ * `registered`: the types this deployment registers itself — a project's own build of a plugin the space also lists
12
+ * on its CDN. Those are never looked for elsewhere: the deployment's copy is the one rendered, and asking the CDN for a
13
+ * manifest nobody will use is a request, and a warning, for nothing.
10
14
  */
11
- export declare const registerExternalPlugins: (pluginManager: PluginManager, offlineData: OfflineDataRaw | undefined, reach?: ManifestReach) => Promise<string[]>;
15
+ export declare const registerExternalPlugins: (pluginManager: PluginManager, offlineData: OfflineDataRaw | undefined, reach?: ManifestReach, registered?: ReadonlySet<string>) => Promise<string[]>;
12
16
  export {};
@@ -97,9 +97,13 @@ var registerPlugin = async (pluginManager, plugin, reach) => {
97
97
  /**
98
98
  * Reads plugins listed in offlineData.plugins, fetches their manifests, downloads and caches
99
99
  * JS/CSS via the PluginManager, and returns the effective plugin keys to pass to getEntries().
100
+ *
101
+ * `registered`: the types this deployment registers itself — a project's own build of a plugin the space also lists
102
+ * on its CDN. Those are never looked for elsewhere: the deployment's copy is the one rendered, and asking the CDN for a
103
+ * manifest nobody will use is a request, and a warning, for nothing.
100
104
  */
101
- var registerExternalPlugins = async (pluginManager, offlineData, reach = { allowPrivateHosts: false }) => {
102
- const plugins = offlineData?.plugins;
105
+ var registerExternalPlugins = async (pluginManager, offlineData, reach = { allowPrivateHosts: false }, registered = /* @__PURE__ */ new Set()) => {
106
+ const plugins = offlineData?.plugins?.filter((plugin) => !registered.has(plugin.type));
103
107
  if (!plugins || plugins.length === 0) return [];
104
108
  return (await Promise.all(plugins.map((p) => registerPlugin(pluginManager, p, reach)))).filter((k) => k !== null);
105
109
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.37.8",
3
+ "version": "0.37.9",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -79,8 +79,8 @@
79
79
  "type": "module",
80
80
  "sideEffects": false,
81
81
  "dependencies": {
82
- "@plitzi/plitzi-sdk": "0.37.8",
83
- "@plitzi/sdk-shared": "0.37.8",
82
+ "@plitzi/plitzi-sdk": "0.37.9",
83
+ "@plitzi/sdk-shared": "0.37.9",
84
84
  "ejs": "^6.0.1",
85
85
  "esbuild": "^0.28.2",
86
86
  "jsonwebtoken": "^9.0.3",
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: plitzi-authoring
3
3
  description: >-
4
- Write or change a Plitzi space or template in TypeScript with @plitzi/sdk-authoring — pages, layouts, elements,
5
- CSS classes, data bindings, providers and flows — instead of hand-writing schema JSON. Use whenever the task is to
4
+ Write or change a Plitzi space or template in TypeScript with @plitzi/sdk-authoring — pages, layouts, components,
5
+ elements, CSS classes, data bindings, providers and flows — instead of hand-writing schema JSON. Use whenever the task is to
6
6
  create, extend, restyle or fix a space: adding a page or a section, sharing a header across pages, binding an
7
7
  element to data, showing or hiding something, wiring what happens on click, or turning an exported JSON into code.
8
8
  ---
@@ -52,10 +52,12 @@ type, the exported catalogues answer faster (`elementDefaultAttributes`, `elemen
52
52
  1. **Name what is referred to.** Give an `id` to every element a binding, a flow or a test addresses. Ids are ONE
53
53
  namespace for the whole space — layouts and every page share it — so an element built by a helper that runs more
54
54
  than once gets its id prefixed by what it is for: `` `${pageId}-foot` ``.
55
- 2. **Share with classes and layouts, never with copies.** A look used twice is a `styles()` class. Chrome shown on
56
- several pages — a header, a sidebar, a footer — is a **layout** the pages name, written once. A menu, a card grid,
57
- a list of steps is DATA mapped to elements (`entries.map(entry => link(…))`), not a block pasted per item. See
58
- [layouts and duplication](reference/layouts.md).
55
+ 2. **Share with classes, layouts and components, never with copies.** A look used twice is a `styles()` class.
56
+ Chrome shown on several pages — a header, a sidebar, a footer — is a **layout** the pages name, written once. A block
57
+ placed many times with different content — a product card, a testimonial — is a **component** placed with
58
+ `component(id, { props })`. A menu, a card grid, a list of steps is DATA mapped to elements
59
+ (`entries.map(entry => link(…))`), not a block pasted per item. See [layouts and duplication](reference/layouts.md)
60
+ and [components](reference/components.md).
59
61
  3. **One element, one base selector.** An element takes a shared `class` OR its own `css`, never both — authoring
60
62
  refuses the pair. A look that never changes is its own class.
61
63
  4. **Visible by default; decide which way the logic flips.** An element is on screen unless something hides it. When
@@ -158,16 +160,17 @@ link({ href: 'about' }); link({ href: '/games/nebula', mode: 'internal' }); link
158
160
  | --- | --- |
159
161
  | [elements-and-styles.md](reference/elements-and-styles.md) | Any element or CSS: factories, fields, classes, states, variants, tokens, fonts, lists, links |
160
162
  | [layouts.md](reference/layouts.md) | Anything shown on more than one page; menus; reducing duplication of elements and styles |
163
+ | [components.md](reference/components.md) | One block placed many times — a card, a tier, a testimonial: props, slots, binding a row into one, why it is closed |
161
164
  | [data-and-visibility.md](reference/data-and-visibility.md) | Bindings, providers, offline data, loading/empty/error states, live data, caching, showing and hiding, kept state |
162
165
  | [lists.md](reference/lists.md) | Rendering rows, filtering and sorting them, a detail page for one record |
163
166
  | [validation.md](reference/validation.md) | How `authorSpace` checks, the loop that wastes no attempts, and what it cannot see |
164
167
  | [authoring-errors.md](reference/authoring-errors.md) | What `authorSpace` refuses or warns about, and what to write instead |
165
168
  | [templates.md](reference/templates.md) | Any `{{ … }}` or `{% … %}`: where it runs, naming sources, filters, tests, dates |
166
169
  | [flows.md](reference/flows.md) | Clicks, submits, page loads, server actions, realtime channels, modals, state |
167
- | [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering |
170
+ | [plugins.md](reference/plugins.md) | A component of your own: props, binding them, writing state, channels, registering, behaving in the builder |
168
171
  | [structure.md](reference/structure.md) | A space bigger than one screen: files, helpers, naming, keeping it short |
169
172
  | [testing.md](reference/testing.md) | Any test: `inspectPage` (one call, every problem), handles, fixtures, catching a flash from the first frame, shortcuts, counting renders |
170
173
  | [performance.md](reference/performance.md) | A page with many elements, a busy flow, something that feels slow: what renders, what it costs, how to measure it |
171
- | [templates-and-export.md](reference/templates-and-export.md) | Publishing a template; turning an exported JSON into code |
174
+ | [templates-and-export.md](reference/templates-and-export.md) | Publishing a template; turning an exported JSON — or a server action — into code |
172
175
  | [accessibility.md](reference/accessibility.md) | Icon buttons, fields, images, clickable cards, toggles, headings, landmarks, a canvas — anything a screen reader or a browser agent has to use |
173
176
  | [review-checklist.md](reference/review-checklist.md) | Before you say it is done |
@@ -0,0 +1,75 @@
1
+ # Components: a block written once, placed anywhere
2
+
3
+ A product card, a pricing tier, a testimonial: the same block on several pages, or several times on one, differing
4
+ only in what it says. A helper function that returns `ElementSpec` writes a COPY of the block wherever it is called —
5
+ the document holds every copy, and a person who later edits one in the builder edits that one. A **component** is the
6
+ block written ONCE: the document holds one tree, every placement is an instance of it, and an edit to it is an edit to
7
+ all of them, in code and in the builder alike.
8
+
9
+ ```ts
10
+ import { component, container, heading, text } from '@plitzi/sdk-authoring';
11
+
12
+ import type { ComponentSpec } from '@plitzi/sdk-authoring';
13
+
14
+ export const productCard: ComponentSpec = {
15
+ id: 'product-card',
16
+ label: 'Product card',
17
+ props: {
18
+ title: { type: 'text', description: 'The product name', required: true },
19
+ price: { type: 'number', description: 'In euros' },
20
+ featured: { type: 'boolean', description: 'Wears the accent border', default: false }
21
+ },
22
+ slots: ['product-card-actions'],
23
+ root: container({
24
+ id: 'product-card-root',
25
+ class: card,
26
+ children: [
27
+ heading({ id: 'product-card-title', bind: { content: 'props.title' } }),
28
+ text({ id: 'product-card-price', content: '{{ props.price }} €' }),
29
+ container({ id: 'product-card-actions' })
30
+ ]
31
+ })
32
+ };
33
+
34
+ // in the space
35
+ components: [productCard],
36
+ pages: [{ name: 'Shop', slug: 'shop', body: [
37
+ component('product-card', { props: { title: 'Lamp', price: 40 }, children: { 'product-card-actions': [button('Buy')] } })
38
+ ] }]
39
+ ```
40
+
41
+ - **Props are attributes of the instance**, declared like a step's params (`type`, `description`, `required`,
42
+ `default`, `options`) and read inside as `{{ props.<name> }}` — in a binding's `source`, a template or an attribute.
43
+ A prop name is letters, digits and `_`, never one an instance already has (`referenceId`, `slot`, `className`…).
44
+ A prop an instance leaves out, with no `default`, is `null`: `{{ props.blurb }}` prints nothing and
45
+ `visible: 'props.featured'` keeps the element hidden.
46
+ - **A prop can be bound** like any attribute: `component('product-card', { bind: [{ to: 'title', source: 'list_products.item.name' }] })`.
47
+ That is how a list row hands its record to a card — bind the record, not each field, if the card reads several:
48
+ a `json` prop `item`, read as `{{ props.item.name }}`.
49
+ - **Slots** are elements of the component's tree that an instance fills. `children: [ … ]` fills a component with one
50
+ slot; `children: { '<slot>': [ … ] }` names the slot for each. What fills a slot is the instance's, not the
51
+ component's: it is authored on the page and sees the page's sources.
52
+ - **Closed.** Inside, nothing but `props` and the globals (`state`, `auth`, `navigation`, `theme`, `variables`,
53
+ `computed`). A binding inside a component onto a provider of the page is refused — "nothing in this component answers
54
+ to …" — because the component would render differently wherever it was placed. Put the provider inside the component,
55
+ or hand the value in as a prop.
56
+ - **Ids are shared** with the whole space, like a layout's: name a component's elements after it (`product-card-title`).
57
+ The same ids render once per instance, each with state of its own.
58
+ - **State of its own per instance.** A flow inside a component that names one of its elements —
59
+ `toggleElement({ category: 'state', key: 'visibility' }, 'product-card-details')` — acts on THAT instance's copy.
60
+ Global state is shared: `toggleInState({ key: 'favourites', value: '{{ props.product.id }}' })` from any card.
61
+ - **Components nest**: a component may place another. One that places itself, directly or through another, is refused.
62
+ - **Checked when written**: a component the space does not declare, a prop it does not declare, a required one left out,
63
+ a value of the wrong kind (`featured: 'true'` for a boolean) and a child for a slot it does not have each throw, by name.
64
+
65
+ ## Component, layout or helper?
66
+
67
+ | You have | Write |
68
+ | --- | --- |
69
+ | Chrome a page renders INSIDE — top bar, sidebar, footer | a **layout** ([layouts.md](layouts.md)) |
70
+ | One block placed many times, differing in its content | a **component** |
71
+ | Many blocks of DIFFERENT structure built by the same logic | a **helper** returning `ElementSpec` |
72
+ | A list of the same block over data | a **list** whose row is the block — or places the component ([lists.md](lists.md)) |
73
+
74
+ A component is also what a person editing the space in the builder will find in its Components panel, open and edit
75
+ once; a helper's output is, to them, a pile of copies.
@@ -39,7 +39,7 @@ never a nested tree. Use the step builders — they fill in where a step runs an
39
39
 
40
40
  ## What a trigger hands the flow
41
41
 
42
- Name the trigger (`named('changed', onChange())`) and read its payload as `{{ changed.<field> }}`:
42
+ Name the trigger (`named('changed', on('onChange'))`) and read its payload as `{{ changed.<field> }}`:
43
43
 
44
44
  | Trigger | Payload |
45
45
  | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
@@ -134,6 +134,18 @@ A plugin says what HAPPENED through its events (`onPick`, with the seat in the p
134
134
  decide what that means — write `state`, open a modal, call a server action. Prefer that to writing `state` from inside
135
135
  the component: the flow is visible in the space, the builder shows it, and the same act can come from a button too.
136
136
 
137
+ ## Behaving in the builder
138
+
139
+ The builder draws the element on its canvas while somebody edits the page: a click selects it, a drag moves it, a key
140
+ belongs to the editor.
141
+
142
+ - **Outside preview, do nothing on your own.** `usePlitziServiceContext().settings.previewMode` is `false` while the
143
+ page is edited. Declared interactions are already held back then; your own click handlers, timers, global listeners,
144
+ permission prompts and map gestures are not — gate them on `previewMode`, and still render something to select.
145
+ - **Never the global `window` or `document`.** The canvas is a frame of its own and the code runs in the builder's
146
+ window: listen, measure and go full screen through the node's own page (`ref.current.ownerDocument`, its
147
+ `defaultView`) or `usePlitziServiceContext().utils.getWindow()`. An `instanceof` check takes its class from there too.
148
+
137
149
  ## Components that draw into DOM they do not render
138
150
 
139
151
  A map, a chart library, anything that positions its own markers or popups: its roots are the library's to place. Never
@@ -27,6 +27,12 @@ Never rewrite an exported space by hand. `specFromSpace({ schema, style })` read
27
27
  `compareSpaces(original, authorSpace(spec))` must list nothing but what `corrections` explains. The builder's
28
28
  **Export** does all three. Edit the code from then on — and tidy it as you go ([structure.md](structure.md)).
29
29
 
30
+ A server action reads back the same way: `actionSpecFromEntry({ id, document })` gives the `defineAction` spec that writes
31
+ it — only when that spec writes the very same document, otherwise `reason` says why and it stays JSON — and
32
+ `actionToSource(spec)` writes the module. For the whole space as a project — pages, actions, functions, the source of
33
+ its plugins and runtime, its files — use `plitzi create --from <space>` (and `plitzi pull` later) rather than exporting
34
+ the parts by hand.
35
+
30
36
  For a document you did not author — an export, a JSON edited by hand — run the gate before serving it:
31
37
  `validateSpace({ schema, style })`.
32
38