@plitzi/sdk-server 0.35.8 → 0.35.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @plitzi/sdk-server
2
2
 
3
+ ## 0.35.9
4
+
5
+ ### Patch Changes
6
+
7
+ - Debug a server action's whole flow from the dev-tools, not just its answer.
8
+
9
+ A run now reports an OUTLINE of what it did: every step in order, with its task, how it ended, how long it took, and
10
+ the redacted error of the one that broke the flow — the compensation steps `flow.onFailure` ran included. It carries
11
+ nothing a step was given or returned, so it goes to any page whose debugging the deployment authorized (`devMode`, or
12
+ a space that switched dev tools on for its published site) and to no other. The full trace, with each step's results,
13
+ still reaches only an authoring request or a development server.
14
+
15
+ Runs the SERVER started while rendering a page — the ones feeding `runtime: 'server'` elements — reach the dev-tools
16
+ too, seeded into the page and added to by every `/_rsc` refresh, under the same authorization: a page nobody
17
+ authorized is not told they happened. A response carrying them is never cached. A run that died on its deadline now
18
+ reports the step it was on rather than nothing at all.
19
+
20
+ The Actions tab was rebuilt around it: filters and search over the log, a run list that names the step that broke,
21
+ and a detail panel with the flow's timeline, its compensation shown apart, and what the run carried in and out.
22
+
23
+ - Updated dependencies
24
+ - @plitzi/plitzi-sdk@0.35.9
25
+ - @plitzi/sdk-shared@0.35.9
26
+
3
27
  ## 0.35.8
4
28
 
5
29
  ### Patch Changes
@@ -0,0 +1,24 @@
1
+ import { Schema } from '@plitzi/sdk-shared';
2
+ type DebugConfig = {
3
+ debugMode?: boolean;
4
+ devMode?: boolean;
5
+ };
6
+ type DebugSettings = Pick<Schema['settings'], 'devTools'> | undefined;
7
+ /**
8
+ * Whether a page — and what its flows did — may be debugged. One rule for the render and for every run it reports.
9
+ *
10
+ * The server decides first, through `debugMode`: set, it speaks for every space, and `false` is a refusal no space can
11
+ * turn around. Left unset, a development server authorizes it, and so does a space whose own settings switched
12
+ * `devTools` on. The settings are what this server loaded, never anything the request carries.
13
+ *
14
+ * A page nobody authorized is told nothing: not the steps behind it, not even that a flow ran.
15
+ */
16
+ export declare const authorizesDebugging: (config: DebugConfig, settings: DebugSettings) => boolean;
17
+ /**
18
+ * The same rule, for a request that has not loaded the space yet.
19
+ *
20
+ * The settings are read only when the server left the answer to the space. A space that cannot be read authorizes
21
+ * nothing: debugging is the one thing about a page that fails closed.
22
+ */
23
+ export declare const resolveDebugAuthorization: (config: DebugConfig, loadSettings: () => Promise<DebugSettings>) => Promise<boolean>;
24
+ export {};
@@ -0,0 +1,27 @@
1
+ //#region src/helpers/debugAuthorization.ts
2
+ /**
3
+ * Whether a page — and what its flows did — may be debugged. One rule for the render and for every run it reports.
4
+ *
5
+ * The server decides first, through `debugMode`: set, it speaks for every space, and `false` is a refusal no space can
6
+ * turn around. Left unset, a development server authorizes it, and so does a space whose own settings switched
7
+ * `devTools` on. The settings are what this server loaded, never anything the request carries.
8
+ *
9
+ * A page nobody authorized is told nothing: not the steps behind it, not even that a flow ran.
10
+ */
11
+ var authorizesDebugging = (config, settings) => config.debugMode ?? (config.devMode === true || settings?.devTools === true);
12
+ /**
13
+ * The same rule, for a request that has not loaded the space yet.
14
+ *
15
+ * The settings are read only when the server left the answer to the space. A space that cannot be read authorizes
16
+ * nothing: debugging is the one thing about a page that fails closed.
17
+ */
18
+ var resolveDebugAuthorization = async (config, loadSettings) => {
19
+ if (config.debugMode !== void 0 || config.devMode === true) return authorizesDebugging(config, void 0);
20
+ try {
21
+ return authorizesDebugging(config, await loadSettings());
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+ //#endregion
27
+ export { authorizesDebugging, resolveDebugAuthorization };
@@ -1,4 +1,4 @@
1
- import { ActionErrorReason } from '@plitzi/sdk-shared';
1
+ import { ActionErrorReason, ActionRunStep } from '@plitzi/sdk-shared';
2
2
  /**
3
3
  * A run that stopped for a reason the caller is entitled to know, as opposed to one whose step threw.
4
4
  *
@@ -7,5 +7,10 @@ import { ActionErrorReason } from '@plitzi/sdk-shared';
7
7
  */
8
8
  export declare class ActionRunError extends Error {
9
9
  reason: ActionErrorReason;
10
+ /**
11
+ * The steps a run that STARTED took before it hit this — its deadline, a budget — set by the runner as it rethrows.
12
+ * A refusal before the run began has none. The same outline a completed run returns, and nothing more.
13
+ */
14
+ steps?: ActionRunStep[];
10
15
  constructor(reason: ActionErrorReason, message: string);
11
16
  }
@@ -7,6 +7,11 @@
7
7
  */
8
8
  var ActionRunError = class extends Error {
9
9
  reason;
10
+ /**
11
+ * The steps a run that STARTED took before it hit this — its deadline, a budget — set by the runner as it rethrows.
12
+ * A refusal before the run began has none. The same outline a completed run returns, and nothing more.
13
+ */
14
+ steps;
10
15
  constructor(reason, message) {
11
16
  super(message);
12
17
  this.name = "ActionRunError";
@@ -6,6 +6,19 @@ import { createHash, randomUUID } from "node:crypto";
6
6
  import { triggerCacheMs } from "@plitzi/sdk-shared/actions";
7
7
  //#region src/modules/actions/runtime/renderResolver.ts
8
8
  /**
9
+ * A render run that resolved nothing, carrying what it did.
10
+ *
11
+ * The slice is withheld all the same — `resolveRscData` reads the rejection as the element's error state — but the
12
+ * steps travel with the error, so a page allowed to debug is still shown where the flow broke.
13
+ */
14
+ var RenderRunFailure = class extends Error {
15
+ summary;
16
+ constructor(message, summary, options) {
17
+ super(message, options);
18
+ this.summary = summary;
19
+ }
20
+ };
21
+ /**
9
22
  * What makes two renders the same question.
10
23
  *
11
24
  * Everything that can change the answer, and nothing else: the space and the version being served, the action,
@@ -84,6 +97,15 @@ var createActionResolver = (lookups, module) => {
84
97
  * its own timeout — holding a slot and an outbound connection for a page that had already been answered.
85
98
  */
86
99
  const releaseRenderStop = onAbort(signal, () => run.controller.abort());
100
+ const startedAt = Date.now();
101
+ const summaryOf = (outcome) => ({
102
+ actionId: entry.id,
103
+ runId: run.runId,
104
+ trigger: "render",
105
+ startedAt,
106
+ endedAt: Date.now(),
107
+ ...outcome
108
+ });
87
109
  try {
88
110
  const result = await module.runAction({
89
111
  entry,
@@ -106,16 +128,41 @@ var createActionResolver = (lookups, module) => {
106
128
  * of its error one, and the bindings meant for exactly this (`hasError`, `errorMessage`) never fire: the
107
129
  * page says "nothing here" when the truth is "this could not be fetched".
108
130
  */
109
- if (result.status !== "completed") throw new Error(`Action "${actionId}" ended as ${result.status}`);
110
- return result.output;
131
+ const summary = summaryOf({
132
+ status: result.status,
133
+ steps: result.steps
134
+ });
135
+ if (result.status !== "completed") throw new RenderRunFailure(`Action "${actionId}" ended as ${result.status}`, summary);
136
+ return {
137
+ output: result.output,
138
+ summary
139
+ };
111
140
  } catch (error) {
112
- if (error instanceof ActionRunError) throw new Error(`Action "${actionId}" refused this render: ${error.reason}`, { cause: error });
113
- throw error;
141
+ if (error instanceof RenderRunFailure) throw error;
142
+ if (error instanceof ActionRunError) {
143
+ const status = error.reason === "aborted" ? "aborted" : "failed";
144
+ throw new RenderRunFailure(`Action "${actionId}" refused this render: ${error.reason}`, summaryOf({
145
+ status,
146
+ steps: error.steps ?? [],
147
+ error: error.message
148
+ }), { cause: error });
149
+ }
150
+ throw new RenderRunFailure(error instanceof Error ? error.message : String(error), summaryOf({
151
+ status: "failed",
152
+ steps: [],
153
+ error: "Action failed"
154
+ }), { cause: error });
114
155
  } finally {
115
156
  releaseRenderStop();
116
157
  await module.guards.end(run);
117
158
  }
118
159
  };
160
+ const record = (summary) => {
161
+ (req.ctx.actionRuns ??= []).push({
162
+ ...summary,
163
+ elementId: element.id
164
+ });
165
+ };
119
166
  /**
120
167
  * Everyone asking the same question at once gets one run, and its answer.
121
168
  *
@@ -123,7 +170,14 @@ var createActionResolver = (lookups, module) => {
123
170
  * are a thousand identical flows and a thousand identical outbound requests, arriving at whatever the action
124
171
  * reads all in the same instant.
125
172
  */
126
- return share.run(key, ttlMs, startRun);
173
+ try {
174
+ const { output, summary } = await share.run(key, ttlMs, startRun);
175
+ record(summary);
176
+ return output;
177
+ } catch (error) {
178
+ if (error instanceof RenderRunFailure) record(error.summary);
179
+ throw error;
180
+ }
127
181
  };
128
182
  };
129
183
  //#endregion
@@ -191,6 +191,17 @@ var failureHandlerOf = (nodes, trigger) => {
191
191
  next = nodes[next.afterNode];
192
192
  }
193
193
  };
194
+ /** A step as a debugger may see it: what it was and how it ended, and nothing it was given or returned. */
195
+ var stepOf = (node, { status, phase, startTime, endTime, error }) => ({
196
+ id: node.id,
197
+ title: node.title || node.action,
198
+ action: node.action,
199
+ status,
200
+ phase,
201
+ startTime,
202
+ endTime,
203
+ ...error === void 0 ? {} : { error }
204
+ });
194
205
  var withDefaults = (task, params) => Object.entries(task.params).reduce((acum, [key, param]) => {
195
206
  if (acum[key] === void 0 || acum[key] === "") acum[key] = param.defaultValue ?? "";
196
207
  return acum;
@@ -300,6 +311,7 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
300
311
  });
301
312
  const buildContext = contextFor(controller.signal, createRunFetch(baseFetch, controller.signal, limits, lineage));
302
313
  const trace = [];
314
+ const steps = [];
303
315
  const startedAt = Date.now();
304
316
  let failure;
305
317
  /**
@@ -338,6 +350,65 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
338
350
  const failureHandler = failureHandlerOf(document.nodes, triggerNode);
339
351
  /** The step the flow was running when it stopped — what `{{ failure.step }}` names to the undo. */
340
352
  let stepInFlight = "";
353
+ let stepInFlightSince = 0;
354
+ /**
355
+ * Set once the run stopped awaiting its flow. A step that ignored the abort may still settle afterwards, and it
356
+ * must not land in the run's account after the undo that followed it — it is already recorded as where it stopped.
357
+ */
358
+ let flowSettled = false;
359
+ /**
360
+ * Runs one step and records it twice: whole in the trace, and in `steps` as a debugger may see it.
361
+ *
362
+ * A step that throws is recorded as failed before the error travels on. Where a flow broke is the first thing
363
+ * anybody debugging it asks, and the error the run records afterwards says only that it did.
364
+ */
365
+ const runStep = async (node, phase, build) => {
366
+ const startTime = Date.now();
367
+ const late = () => phase === "flow" && flowSettled;
368
+ try {
369
+ const outcome = await runNode(node, scope, registry, build);
370
+ if (late()) return outcome;
371
+ const endTime = Date.now();
372
+ trace.push({
373
+ node,
374
+ status: outcome.status,
375
+ result: redact(outcome.result),
376
+ postCallbacks: [],
377
+ startTime,
378
+ endTime
379
+ });
380
+ steps.push(stepOf(node, {
381
+ status: outcome.status,
382
+ phase,
383
+ startTime,
384
+ endTime
385
+ }));
386
+ request.onNode?.(node.id, outcome.status);
387
+ scope[node.id] = outcome.result;
388
+ return outcome;
389
+ } catch (error) {
390
+ if (late()) throw error;
391
+ const endTime = Date.now();
392
+ const message = redact(error instanceof Error ? error.message : String(error));
393
+ trace.push({
394
+ node,
395
+ status: "failed",
396
+ result: { error: message },
397
+ postCallbacks: [],
398
+ startTime,
399
+ endTime
400
+ });
401
+ steps.push(stepOf(node, {
402
+ status: "failed",
403
+ phase,
404
+ startTime,
405
+ endTime,
406
+ error: message
407
+ }));
408
+ request.onNode?.(node.id, "failed");
409
+ throw error;
410
+ }
411
+ };
341
412
  const runFlow = async () => {
342
413
  let current = triggerNode;
343
414
  let executed = 0;
@@ -355,19 +426,9 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
355
426
  }
356
427
  executed += 1;
357
428
  if (executed > limits.maxNodes) throw new ActionRunError("over_capacity", `Action exceeded its ${limits.maxNodes} step budget`);
358
- const startTime = Date.now();
359
429
  stepInFlight = next.id;
360
- const outcome = await runNode(next, scope, registry, buildContext);
361
- trace.push({
362
- node: next,
363
- status: outcome.status,
364
- result: redact(outcome.result),
365
- postCallbacks: [],
366
- startTime,
367
- endTime: Date.now()
368
- });
369
- request.onNode?.(next.id, outcome.status);
370
- scope[next.id] = outcome.result;
430
+ stepInFlightSince = Date.now();
431
+ const outcome = await runStep(next, "flow", buildContext);
371
432
  if (next.action === "flow.output" && outcome.status === "success") returned = outcome.result;
372
433
  current = next;
373
434
  next = document.nodes[current.afterNode];
@@ -390,31 +451,28 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
390
451
  message: failure ?? "",
391
452
  status
392
453
  };
454
+ const handlerTime = Date.now();
393
455
  trace.push({
394
456
  node: handler,
395
457
  status: "success",
396
458
  result: redact(scope.failure),
397
459
  postCallbacks: [],
398
- startTime: Date.now(),
399
- endTime: Date.now()
460
+ startTime: handlerTime,
461
+ endTime: handlerTime
400
462
  });
463
+ steps.push(stepOf(handler, {
464
+ status: "success",
465
+ phase: "undo",
466
+ startTime: handlerTime,
467
+ endTime: handlerTime
468
+ }));
401
469
  const runUndo = async () => {
402
470
  let executed = 0;
403
471
  let next = document.nodes[handler.afterNode];
404
472
  while (next) {
405
473
  executed += 1;
406
474
  if (executed > limits.maxNodes) throw new ActionRunError("over_capacity", `Undoing the action exceeded its ${limits.maxNodes} step budget`);
407
- const startTime = Date.now();
408
- const outcome = await runNode(next, scope, registry, buildUndoContext);
409
- trace.push({
410
- node: next,
411
- status: outcome.status,
412
- result: redact(outcome.result),
413
- postCallbacks: [],
414
- startTime,
415
- endTime: Date.now()
416
- });
417
- scope[next.id] = outcome.result;
475
+ await runStep(next, "undo", buildUndoContext);
418
476
  next = document.nodes[next.afterNode];
419
477
  }
420
478
  };
@@ -475,9 +533,18 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
475
533
  });
476
534
  }
477
535
  } finally {
536
+ flowSettled = true;
478
537
  deadline.clear();
479
538
  releaseOuter();
480
539
  }
540
+ const stuck = document.nodes[stepInFlight];
541
+ if (status !== "completed" && stuck && !steps.some((step) => step.phase === "flow" && step.id === stepInFlight)) steps.push(stepOf(stuck, {
542
+ status: "failed",
543
+ phase: "flow",
544
+ startTime: stepInFlightSince,
545
+ endTime: Date.now(),
546
+ ...failure === void 0 ? {} : { error: redact(failure) }
547
+ }));
481
548
  if (status !== "completed" && failureHandler?.enabled) await undo(failureHandler);
482
549
  const output = returned ?? {};
483
550
  await record({
@@ -496,7 +563,10 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
496
563
  })),
497
564
  ...failure === void 0 ? {} : { error: redact(failure) }
498
565
  });
499
- if (fatal) throw fatal;
566
+ if (fatal) {
567
+ fatal.steps = [...steps];
568
+ throw fatal;
569
+ }
500
570
  /**
501
571
  * The trace is COPIED out.
502
572
  *
@@ -507,7 +577,8 @@ var createActionRunner = (config, registry, baseFetch = fetch) => {
507
577
  runId,
508
578
  status,
509
579
  output: redact(output),
510
- trace: [...trace]
580
+ trace: [...trace],
581
+ steps: [...steps]
511
582
  };
512
583
  };
513
584
  return { runAction };
@@ -3,6 +3,7 @@ import { triggerParams } from "../runtime/triggers.js";
3
3
  import { precheckRun } from "../runtime/precheck.js";
4
4
  import onAbort from "../../../helpers/onAbort.js";
5
5
  import { openStream, wantsStream } from "./stream.js";
6
+ import { resolveDebugAuthorization } from "../../../helpers/debugAuthorization.js";
6
7
  import { reportReject } from "../runtime/report.js";
7
8
  import { triggerAccess } from "@plitzi/sdk-shared/actions";
8
9
  //#region src/modules/actions/transport/callHandler.ts
@@ -156,6 +157,24 @@ var handleActionCall = async (deps) => {
156
157
  const abortRun = () => run.controller.abort();
157
158
  const releaseAbort = onAbort(signal, abortRun);
158
159
  const stream = wantsStream(req.headers.accept) ? openStream(raw, abortRun, run.runId) : void 0;
160
+ /**
161
+ * What a debugger may be told about this run, beside its answer.
162
+ *
163
+ * The OUTLINE — which steps ran, how each ended, where the flow broke — goes to a page whose debugging the
164
+ * deployment authorized: a dev server, or a space that switched dev tools on for its own site. Nothing else gets
165
+ * it, and it carries nothing a step was given or returned.
166
+ *
167
+ * The full TRACE, with every step's results, only to an authoring request or a development server: those results
168
+ * can hold another visitor's data, and sending them to an authoring request is what puts a SERVER run in the same
169
+ * Workflow debugger as a client one. Both are gated on the ACTION too — one behind a session tells an anonymous
170
+ * caller nothing about its flow.
171
+ *
172
+ * Answered once the run is over, for the answer and for the failure alike: a run that died on its deadline is
173
+ * exactly the one somebody is debugging.
174
+ */
175
+ const readsRun = mayReadTrace(entryPoint, req.ctx.user);
176
+ const fullTrace = readsRun && (authoring === true || config.devMode === true);
177
+ const showsOutline = async () => readsRun && (fullTrace || await resolveDebugAuthorization(config, async () => (await config.adapters.getOfflineData(spaceId, environment, revision))?.schema.settings));
159
178
  let outcome;
160
179
  try {
161
180
  const result = await module.runAction({
@@ -188,13 +207,15 @@ var handleActionCall = async (deps) => {
188
207
  } : {}
189
208
  });
190
209
  outcome = result;
210
+ const outline = await showsOutline();
191
211
  if (stream) {
192
212
  stream.send({
193
213
  event: "done",
194
214
  data: {
195
215
  runId: result.runId,
196
216
  status: result.status,
197
- output: result.output
217
+ output: result.output,
218
+ ...outline ? { steps: result.steps } : {}
198
219
  }
199
220
  });
200
221
  stream.close();
@@ -205,25 +226,32 @@ var handleActionCall = async (deps) => {
205
226
  status: result.status,
206
227
  output: result.output
207
228
  };
208
- if ((authoring === true || config.devMode === true) && mayReadTrace(entryPoint, req.ctx.user)) payload.trace = result.trace;
229
+ if (outline) payload.steps = result.steps;
230
+ if (fullTrace) payload.trace = result.trace;
209
231
  send(res, 200, payload, result.runId);
210
232
  } catch (error) {
211
233
  const reason = error instanceof ActionRunError ? error.reason : "failed";
212
234
  const message = error instanceof ActionRunError ? error.message : "Action failed";
213
235
  if (!(error instanceof ActionRunError)) console.error("[Actions] run failed:", error);
236
+ const steps = error instanceof ActionRunError && error.steps && await showsOutline() ? error.steps : void 0;
214
237
  if (stream) {
215
238
  stream.send({
216
239
  event: "error",
217
240
  data: {
218
241
  runId: run.runId,
219
242
  error: message,
220
- reason
243
+ reason,
244
+ ...steps ? { steps } : {}
221
245
  }
222
246
  });
223
247
  stream.close();
224
248
  return;
225
249
  }
226
- fail(res, reason, message, run.runId);
250
+ send(res, STATUS_BY_REASON[reason], {
251
+ error: message,
252
+ reason,
253
+ ...steps ? { steps } : {}
254
+ }, run.runId);
227
255
  } finally {
228
256
  releaseAbort();
229
257
  await module.guards.end(run, outcome);
@@ -1,5 +1,5 @@
1
1
  import { ActionEmailSender } from './runtime/email';
2
- import { ActionEmailConfig, ActionEntry, ActionLimits, ActionRejectRecord, ActionRunRecord, ActionRunStatus, ActionTriggerType, ConnectorManifest, Environment, InteractionCallbackParam, InteractionNode, InteractionNodeStatus, SpaceRevision, SSRUser } from '@plitzi/sdk-shared';
2
+ import { ActionEmailConfig, ActionEntry, ActionLimits, ActionRejectRecord, ActionRunRecord, ActionRunStatus, ActionRunStep, ActionTriggerType, ConnectorManifest, Environment, InteractionCallbackParam, InteractionNode, InteractionNodeStatus, SpaceRevision, SSRUser } from '@plitzi/sdk-shared';
3
3
  /** Resolved secret material, as the connector engine already models it. */
4
4
  export type ActionCredential = Record<string, string>;
5
5
  /**
@@ -236,4 +236,9 @@ export type ActionRunResult = {
236
236
  output: Record<string, unknown>;
237
237
  /** The same shape the dev-tools Interactions panel renders. Redacted before it leaves the process. */
238
238
  trace: InteractionNode[];
239
+ /**
240
+ * What the run did, step by step, without what any step was given or returned — the part a debugger may show to
241
+ * anybody allowed to debug the page. The trace above is for authoring and development servers alone.
242
+ */
243
+ steps: ActionRunStep[];
239
244
  };
@@ -1,3 +1,4 @@
1
+ import { resolveDebugAuthorization } from "../../helpers/debugAuthorization.js";
1
2
  import { DEFAULT_TTL_MS } from "../../helpers/cache/defaults.js";
2
3
  import { buildRscCacheKey } from "../../helpers/cache/keys.js";
3
4
  import { readDraftToken } from "../../core/previewToken.js";
@@ -98,6 +99,7 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
98
99
  res.send(cached);
99
100
  return;
100
101
  }
102
+ const loadOfflineData = createOfflineDataLoader(() => config.adapters.getOfflineData(spaceId, environment, revision));
101
103
  let rscData;
102
104
  try {
103
105
  rscData = await config.adapters.getRscData({
@@ -107,7 +109,7 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
107
109
  revision,
108
110
  user: req.ctx.user,
109
111
  ids,
110
- loadOfflineData: createOfflineDataLoader(() => config.adapters.getOfflineData(spaceId, environment, revision))
112
+ loadOfflineData
111
113
  });
112
114
  } catch (err) {
113
115
  console.error("[RSC] getRscData error:", err);
@@ -115,16 +117,18 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
115
117
  res.send(JSON.stringify({ error: "RSC data fetch failed" }));
116
118
  return;
117
119
  }
120
+ const actionRuns = Boolean(req.ctx.actionRuns?.length) && await resolveDebugAuthorization(config, async () => (await loadOfflineData())?.schema.settings) ? req.ctx.actionRuns : void 0;
118
121
  const payload = {
119
122
  version: 1,
120
123
  transport: "json",
121
124
  spaceId,
122
125
  environment,
123
126
  revision,
124
- ...rscData
127
+ ...rscData,
128
+ ...actionRuns ? { actionRuns } : {}
125
129
  };
126
130
  const payloadStr = JSON.stringify(payload);
127
- if (cacheKey) cache?.set(cacheKey, payloadStr);
131
+ if (cacheKey && !actionRuns?.length) cache?.set(cacheKey, payloadStr);
128
132
  res.setHeader("Content-Type", "application/json; charset=utf-8");
129
133
  res.setHeader("Cache-Control", cacheControl);
130
134
  res.setHeader("X-Cache", "MISS");
@@ -2,8 +2,10 @@ import { TtlCache } from '../../helpers/cache';
2
2
  import { RequestMetrics } from '../../helpers/metrics';
3
3
  import { PluginManager } from '../../plugins/manager';
4
4
  import { Environment, OfflineDataRaw, SSRPageServerConfig, SSRRenderResult, SSRRequest, SSRTemplateFn } from '@plitzi/sdk-shared';
5
+ /** `cacheable` is false for a body that must not be served to anybody else: it carries this request's own runs. */
5
6
  export type BuildBodyResult = {
6
7
  body?: string;
7
8
  result: SSRRenderResult;
9
+ cacheable: boolean;
8
10
  };
9
11
  export declare const buildBody: (req: SSRRequest, config: SSRPageServerConfig, spaceId: number, environment: Environment, revision: number, renderFn: SSRTemplateFn, pluginManager: PluginManager, offlineDataCache?: TtlCache<string>, metrics?: RequestMetrics, offlineDataOverride?: OfflineDataRaw) => Promise<BuildBodyResult>;
@@ -12,7 +12,10 @@ var buildBody = async (req, config, spaceId, environment, revision, renderFn, pl
12
12
  ssrResult: result
13
13
  })).trim();
14
14
  metrics?.record("react", Math.round(performance.now() - reactStart));
15
- if (result.redirect !== void 0) return { result };
15
+ if (result.redirect !== void 0) return {
16
+ result,
17
+ cacheable: prep.cacheable
18
+ };
16
19
  const templateStart = metrics ? performance.now() : 0;
17
20
  const body = renderFn({
18
21
  ...prep.templateParams,
@@ -21,7 +24,8 @@ var buildBody = async (req, config, spaceId, environment, revision, renderFn, pl
21
24
  metrics?.record("template", Math.round(performance.now() - templateStart));
22
25
  return {
23
26
  body,
24
- result
27
+ result,
28
+ cacheable: prep.cacheable
25
29
  };
26
30
  };
27
31
  //#endregion
@@ -57,13 +57,13 @@ var renderSSR = async (req, res, config, renderFn, pluginManager, caches) => {
57
57
  await streamBody(req, res, config, spaceId, environment, revision, renderFn, pluginManager, caches.offlineData, htmlCache, cacheKey, metrics, offlineDataOverride);
58
58
  return;
59
59
  }
60
- const { body, result } = await buildBody(req, config, spaceId, environment, revision, renderFn, pluginManager, caches.offlineData, metrics, offlineDataOverride);
60
+ const { body, result, cacheable } = await buildBody(req, config, spaceId, environment, revision, renderFn, pluginManager, caches.offlineData, metrics, offlineDataOverride);
61
61
  if (metrics) {
62
62
  res.setHeader("Server-Timing", metrics.toServerTimingHeader());
63
63
  metrics.log(`${req.method} ${req.path}`);
64
64
  }
65
65
  if (applySSRResult(res, result)) return;
66
- if (htmlCache && cacheKey && body !== void 0) htmlCache.set(cacheKey, body);
66
+ if (htmlCache && cacheKey && body !== void 0 && cacheable) htmlCache.set(cacheKey, body);
67
67
  res.send(body ?? "");
68
68
  };
69
69
  //#endregion
@@ -9,5 +9,7 @@ export type RenderPrep = {
9
9
  templateParams: SSRTemplateProps & {
10
10
  offlineData: string;
11
11
  };
12
+ /** False for a render that must not be served to anybody else from a cache: it carries this request's own runs. */
13
+ cacheable: boolean;
12
14
  };
13
15
  export declare const prepareRender: (req: SSRRequest, config: SSRPageServerConfig, spaceId: number, environment: Environment, revision: number, pluginManager: PluginManager, offlineDataCache?: TtlCache<string>, metrics?: RequestMetrics, offlineDataOverride?: OfflineDataRaw) => Promise<RenderPrep>;
@@ -1,3 +1,4 @@
1
+ import { authorizesDebugging } from "../../helpers/debugAuthorization.js";
1
2
  import { matchRscPage } from "../rsc/matchRscPage.js";
2
3
  import { readCookie } from "../../helpers/readCookie.js";
3
4
  import { buildOfflineDataCacheKey } from "../../helpers/cache/keys.js";
@@ -64,8 +65,16 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
64
65
  * so does the space itself (`settings.devTools`) — an owner inspecting their own published site. The space is read
65
66
  * from what this server loaded, never from the request, so a visitor has no say in it.
66
67
  */
67
- const debugAuthorized = !Boolean(req.query["__pt"]) && (config.debugMode ?? (config.devMode === true || schema?.settings.devTools === true));
68
+ const debugAuthorized = !Boolean(req.query["__pt"]) && authorizesDebugging(config, schema?.settings);
68
69
  const debugRendered = resolveDebugMode(debugAuthorized, readCookie(req.headers.cookie, debugCookieName(req.headers.host)));
70
+ /**
71
+ * The runs this render's server elements started, handed to a page that may debug them — and to no other.
72
+ *
73
+ * Same authorization as the panel itself: a page nobody authorized is told nothing about the flows behind it, not
74
+ * even that they ran. A render carrying them is also one nobody else may be served from a cache: those runs are
75
+ * this request's.
76
+ */
77
+ const actionRuns = debugAuthorized && req.ctx.actionRuns?.length ? req.ctx.actionRuns : void 0;
69
78
  const { degrade, analytics } = req.ctx.meter ?? {};
70
79
  const clientAnalytics = analytics ? {
71
80
  ...analytics,
@@ -98,7 +107,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
98
107
  ...theme ? { theme } : {},
99
108
  ...clientAnalytics ? { analytics: clientAnalytics } : {},
100
109
  ...branding ? { branding } : {},
101
- ...overQuota ? { overQuota } : {}
110
+ ...overQuota ? { overQuota } : {},
111
+ ...actionRuns ? { actionRuns } : {}
102
112
  }));
103
113
  const pluginNames = req.ctx.spaceDeployment?.pluginNames ?? [];
104
114
  const pluginSources = req.ctx.spaceDeployment?.pluginSources;
@@ -165,7 +175,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
165
175
  debugMode: debugAuthorized,
166
176
  ssrOnly: config.ssrOnly === true,
167
177
  offlineData: offlineDataStr
168
- }
178
+ },
179
+ cacheable: actionRuns === void 0
169
180
  };
170
181
  };
171
182
  //#endregion
@@ -19,7 +19,7 @@ var streamBody = async (req, res, config, spaceId, environment, revision, render
19
19
  const tail = sentinelIdx >= 0 ? fullTemplate.slice(sentinelIdx + 18) : "";
20
20
  const result = {};
21
21
  await new Promise((resolve, reject) => {
22
- const chunks = cacheKey && htmlCache ? [] : null;
22
+ const chunks = cacheKey && htmlCache && prep.cacheable ? [] : null;
23
23
  const writable = new Writable({
24
24
  write(chunk, _enc, cb) {
25
25
  chunks?.push(chunk);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.35.8",
3
+ "version": "0.35.9",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -58,8 +58,8 @@
58
58
  "type": "module",
59
59
  "sideEffects": false,
60
60
  "dependencies": {
61
- "@plitzi/plitzi-sdk": "0.35.8",
62
- "@plitzi/sdk-shared": "0.35.8",
61
+ "@plitzi/plitzi-sdk": "0.35.9",
62
+ "@plitzi/sdk-shared": "0.35.9",
63
63
  "ejs": "^6.0.1",
64
64
  "esbuild": "^0.28.2",
65
65
  "jsonwebtoken": "^9.0.3",