@plitzi/sdk-server 0.38.4 → 0.38.6

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 (118) hide show
  1. package/CHANGELOG.md +350 -0
  2. package/README.md +126 -17
  3. package/dist/actions.d.ts +8 -5
  4. package/dist/actions.js +3 -1
  5. package/dist/adapters/jsonAdapters.d.ts +1 -1
  6. package/dist/adapters/jsonAdapters.js +16 -3
  7. package/dist/core/configSeam.js +20 -4
  8. package/dist/core/createServer.js +3 -2
  9. package/dist/core/handlers/socialRouteHandlers.js +6 -1
  10. package/dist/core/http/stages/devReload.d.ts +11 -0
  11. package/dist/core/http/stages/devReload.js +17 -0
  12. package/dist/core/server/baseServer.d.ts +2 -0
  13. package/dist/core/server/baseServer.js +3 -0
  14. package/dist/core/server/pageServer.js +18 -0
  15. package/dist/core/services/registry.js +2 -0
  16. package/dist/core/services/rsc.js +1 -1
  17. package/dist/core/services/spaceDocument.d.ts +2 -0
  18. package/dist/core/services/spaceDocument.js +11 -0
  19. package/dist/functions-api.d.ts +6 -1
  20. package/dist/functionsRunner.d.ts +5 -0
  21. package/dist/functionsRunner.js +2 -1
  22. package/dist/helpers/hydrationPayload.d.ts +25 -4
  23. package/dist/helpers/hydrationPayload.js +36 -30
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +2 -2
  26. package/dist/modules/actions/index.d.ts +11 -2
  27. package/dist/modules/actions/index.js +29 -7
  28. package/dist/modules/actions/runtime/fileKv.d.ts +28 -0
  29. package/dist/modules/actions/runtime/fileKv.js +95 -0
  30. package/dist/modules/actions/runtime/fileKv.test.d.ts +1 -0
  31. package/dist/modules/actions/runtime/memoryKv.d.ts +23 -0
  32. package/dist/modules/actions/runtime/memoryKv.js +39 -24
  33. package/dist/modules/actions/runtime/namespaceKv.d.ts +2 -0
  34. package/dist/modules/actions/runtime/namespaceKv.js +5 -3
  35. package/dist/modules/actions/runtime/renderResolver.d.ts +3 -2
  36. package/dist/modules/actions/runtime/renderResolver.js +13 -9
  37. package/dist/modules/actions/runtime/renderShare.d.ts +7 -24
  38. package/dist/modules/actions/runtime/renderShare.js +47 -13
  39. package/dist/modules/actions/runtime/runAction.js +15 -1
  40. package/dist/modules/actions/runtime/scope.js +16 -1
  41. package/dist/modules/actions/runtime/signing.js +4 -1
  42. package/dist/modules/actions/runtime/taskContext.d.ts +7 -1
  43. package/dist/modules/actions/runtime/taskContext.js +6 -2
  44. package/dist/modules/actions/tasks/registry.d.ts +3 -1
  45. package/dist/modules/actions/tasks/registry.js +15 -1
  46. package/dist/modules/actions/types.d.ts +15 -2
  47. package/dist/modules/functions/config.d.ts +6 -0
  48. package/dist/modules/functions/context.d.ts +4 -2
  49. package/dist/modules/functions/context.js +43 -21
  50. package/dist/modules/functions/load.d.ts +8 -1
  51. package/dist/modules/functions/load.js +8 -4
  52. package/dist/modules/functions/manifest.d.ts +4 -1
  53. package/dist/modules/functions/manifest.js +14 -2
  54. package/dist/modules/functions/native.d.ts +2 -1
  55. package/dist/modules/functions/native.js +2 -2
  56. package/dist/modules/functions/plugins.test.d.ts +1 -0
  57. package/dist/modules/functions/routes.d.ts +8 -3
  58. package/dist/modules/functions/routes.js +41 -21
  59. package/dist/modules/functions/scope.d.ts +30 -0
  60. package/dist/modules/functions/scope.js +28 -0
  61. package/dist/modules/functions/space.d.ts +10 -5
  62. package/dist/modules/functions/space.js +37 -15
  63. package/dist/modules/rsc/connectorRscData.d.ts +3 -1
  64. package/dist/modules/rsc/connectorRscData.js +8 -4
  65. package/dist/modules/rsc/handler.d.ts +3 -16
  66. package/dist/modules/rsc/handler.js +10 -4
  67. package/dist/modules/rsc/publicFileResolver.d.ts +15 -12
  68. package/dist/modules/rsc/publicFileResolver.js +56 -24
  69. package/dist/modules/rsc/resolveRscData.d.ts +3 -1
  70. package/dist/modules/rsc/resolveRscData.js +8 -5
  71. package/dist/modules/runtime/bundle.d.ts +5 -0
  72. package/dist/modules/runtime/bundle.js +20 -5
  73. package/dist/modules/sqlite/kv.d.ts +34 -0
  74. package/dist/modules/sqlite/kv.js +99 -0
  75. package/dist/modules/sqlite/kv.test.d.ts +1 -0
  76. package/dist/modules/ssr/Component.d.ts +2 -1
  77. package/dist/modules/ssr/Component.js +2 -1
  78. package/dist/modules/ssr/prepareRender.js +48 -6
  79. package/dist/modules/ssr/spaceDocument.d.ts +25 -0
  80. package/dist/modules/ssr/spaceDocument.js +72 -0
  81. package/dist/modules/ssr/spaceDocument.test.d.ts +1 -0
  82. package/dist/modules/ssr/testing/offlineData.d.ts +5 -0
  83. package/dist/modules/ssr/views/template.ejs +122 -20
  84. package/dist/plugins/compile.js +6 -21
  85. package/dist/plugins/compile.test.d.ts +1 -0
  86. package/dist/plugins/copy.d.ts +5 -0
  87. package/dist/plugins/copy.js +16 -1
  88. package/dist/plugins/copy.test.d.ts +1 -0
  89. package/dist/plugins/manager.d.ts +13 -0
  90. package/dist/plugins/manager.js +40 -11
  91. package/dist/plugins/watch.d.ts +12 -0
  92. package/dist/plugins/watch.js +79 -0
  93. package/dist/runtime.d.ts +1 -1
  94. package/dist/runtime.js +2 -2
  95. package/dist/sqlite.d.ts +17 -0
  96. package/dist/sqlite.js +2 -0
  97. package/package.json +8 -3
  98. package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
  99. package/skills/plitzi-authoring/SKILL.md +20 -19
  100. package/skills/plitzi-authoring/recipes/plugin.ts +1 -1
  101. package/skills/plitzi-authoring/recipes/server-data.ts +3 -3
  102. package/skills/plitzi-authoring/recipes/show-data.ts +3 -2
  103. package/skills/plitzi-authoring/recipes/typed-data.ts +1 -1
  104. package/skills/plitzi-authoring/reference/authoring-errors.md +8 -2
  105. package/skills/plitzi-authoring/reference/colours-and-motion.md +3 -1
  106. package/skills/plitzi-authoring/reference/data-and-visibility.md +24 -22
  107. package/skills/plitzi-authoring/reference/drawing.md +24 -0
  108. package/skills/plitzi-authoring/reference/efficiency.md +19 -2
  109. package/skills/plitzi-authoring/reference/flows.md +3 -0
  110. package/skills/plitzi-authoring/reference/layouts.md +8 -23
  111. package/skills/plitzi-authoring/reference/performance.md +2 -2
  112. package/skills/plitzi-authoring/reference/plugins.md +51 -38
  113. package/skills/plitzi-authoring/reference/review-checklist.md +2 -0
  114. package/skills/plitzi-authoring/reference/structure.md +32 -1
  115. package/skills/plitzi-authoring/reference/templates.md +5 -2
  116. package/skills/plitzi-authoring/reference/testing.md +9 -2
  117. package/skills/plitzi-authoring/reference/typed-sources.md +3 -1
  118. package/skills/plitzi-authoring/reference/validation.md +1 -1
@@ -1,14 +1,4 @@
1
1
  //#region src/modules/actions/runtime/memoryKv.ts
2
- /**
3
- * The `kv` tasks' fallback store: in this process, and nowhere else.
4
- *
5
- * Real enough for a single replica and for local work, and deliberately not pretending otherwise — a deployment
6
- * running several replicas passes an adapter over something shared, because a counter that only counts its own
7
- * replica is a rate limit that multiplies by the number of them.
8
- *
9
- * An adapter like any other: it stores strings and obeys no rules of its own. What a counter DOES lives in
10
- * `createKvStore`, so the in-process store and a deployment's own behave identically rather than nearly so.
11
- */
12
2
  /** How many writes go by before the map is swept. Cheap enough to be unnoticeable, often enough to bound it. */
13
3
  var SWEEP_EVERY = 256;
14
4
  /** Every method of the store, for the copy a server's workers share (see `fleet/stores.ts`). */
@@ -20,8 +10,16 @@ var KV_METHODS = {
20
10
  expire: true,
21
11
  swap: true
22
12
  };
23
- var createMemoryKv = () => {
24
- const entries = /* @__PURE__ */ new Map();
13
+ /**
14
+ * The store's rules over a map somebody holds: what `createMemoryKv` is, and what a store that keeps the map
15
+ * somewhere (`createFileKv`) is too — so the two answer identically and differ only in where the map lives.
16
+ *
17
+ * An adapter like any other: it stores strings and obeys no rules of its own. What a counter DOES lives in
18
+ * `createKvStore`, so the in-process store and a deployment's own behave identically rather than nearly so.
19
+ *
20
+ * `changed` is told after every write that changed the map — an expired entry swept or read past included.
21
+ */
22
+ var createMapKv = (entries, changed = () => void 0) => {
25
23
  let writes = 0;
26
24
  /**
27
25
  * Expiry only ever happened on READ, which is fine for a counter that is read as often as it is written and
@@ -33,63 +31,80 @@ var createMemoryKv = () => {
33
31
  writes += 1;
34
32
  if (writes % SWEEP_EVERY !== 0) return;
35
33
  const now = Date.now();
34
+ const before = entries.size;
36
35
  entries.forEach((entry, key) => {
37
36
  if (entry.expiresAt !== void 0 && entry.expiresAt <= now) entries.delete(key);
38
37
  });
38
+ if (entries.size !== before) changed();
39
39
  };
40
40
  const read = (key) => {
41
41
  const entry = entries.get(key);
42
42
  if (!entry) return;
43
43
  if (entry.expiresAt !== void 0 && entry.expiresAt <= Date.now()) {
44
44
  entries.delete(key);
45
+ changed();
45
46
  return;
46
47
  }
47
48
  return entry;
48
49
  };
49
50
  const expiry = (ttlSeconds) => ttlSeconds === void 0 ? void 0 : Date.now() + ttlSeconds * 1e3;
51
+ const write = (key, entry) => {
52
+ entries.set(key, entry);
53
+ changed();
54
+ sweep();
55
+ };
50
56
  return {
51
57
  get: (key) => Promise.resolve(read(key)?.value),
52
58
  set: (key, value, ttlSeconds) => {
53
- entries.set(key, {
59
+ write(key, {
54
60
  value,
55
61
  expiresAt: expiry(ttlSeconds)
56
62
  });
57
- sweep();
58
63
  return Promise.resolve();
59
64
  },
60
65
  delete: (key) => {
61
- entries.delete(key);
66
+ if (entries.delete(key)) changed();
62
67
  return Promise.resolve();
63
68
  },
64
69
  increment: (key, amount) => {
65
70
  const current = read(key);
66
71
  const base = Number(current?.value ?? 0);
67
72
  const next = (Number.isFinite(base) ? base : 0) + amount;
68
- entries.set(key, {
73
+ write(key, {
69
74
  value: String(next),
70
75
  expiresAt: current?.expiresAt
71
76
  });
72
- sweep();
73
77
  return Promise.resolve(next);
74
78
  },
75
79
  expire: (key, ttlSeconds) => {
76
80
  const current = read(key);
77
- if (current) entries.set(key, {
78
- ...current,
79
- expiresAt: expiry(ttlSeconds)
80
- });
81
+ if (current) {
82
+ entries.set(key, {
83
+ ...current,
84
+ expiresAt: expiry(ttlSeconds)
85
+ });
86
+ changed();
87
+ }
81
88
  return Promise.resolve();
82
89
  },
83
90
  swap: (key, expected, next, ttlSeconds) => {
84
91
  if (read(key)?.value !== expected) return Promise.resolve(false);
85
- entries.set(key, {
92
+ write(key, {
86
93
  value: next,
87
94
  expiresAt: expiry(ttlSeconds)
88
95
  });
89
- sweep();
90
96
  return Promise.resolve(true);
91
97
  }
92
98
  };
93
99
  };
100
+ /**
101
+ * The `kv` tasks' fallback store: in this process, and nowhere else — gone when it stops.
102
+ *
103
+ * Real enough for a single replica and for local work, and deliberately not pretending otherwise — a deployment
104
+ * running several replicas passes an adapter over something shared, because a counter that only counts its own
105
+ * replica is a rate limit that multiplies by the number of them. One that must survive a restart passes
106
+ * `createFileKv` (one process) or `createSqliteKv` (`@plitzi/sdk-server/sqlite`).
107
+ */
108
+ var createMemoryKv = () => createMapKv(/* @__PURE__ */ new Map());
94
109
  //#endregion
95
- export { KV_METHODS, createMemoryKv };
110
+ export { KV_METHODS, createMapKv, createMemoryKv };
@@ -1,4 +1,6 @@
1
1
  import { ActionKvStore } from '../types';
2
+ /** Every key `store` is asked for, under `prefix`: a space's own keys, or a plugin's inside the space's. */
3
+ export declare const prefixKv: (store: ActionKvStore, prefix: string) => ActionKvStore;
2
4
  export declare const namespaceKv: (store: ActionKvStore, spaceId: number) => ActionKvStore;
3
5
  /**
4
6
  * Every key a space's store holds, as patterns over the adapter's keys (`createKvStore`'s `prefix`, `kv:` unless it was
@@ -8,8 +8,9 @@ import { LIST_SEGMENT } from "./kvStore.js";
8
8
  * deployment's own tasks inherit it too.
9
9
  */
10
10
  var spaceScope = (spaceId) => `action:${spaceId}:`;
11
- var namespaceKv = (store, spaceId) => {
12
- const scoped = (key) => `${spaceScope(spaceId)}${key}`;
11
+ /** Every key `store` is asked for, under `prefix`: a space's own keys, or a plugin's inside the space's. */
12
+ var prefixKv = (store, prefix) => {
13
+ const scoped = (key) => `${prefix}${key}`;
13
14
  return {
14
15
  get: (key) => store.get(scoped(key)),
15
16
  set: (key, value, ttlSeconds) => store.set(scoped(key), value, ttlSeconds),
@@ -22,10 +23,11 @@ var namespaceKv = (store, spaceId) => {
22
23
  change: (key, change, lifetime) => store.change(scoped(key), change, lifetime)
23
24
  };
24
25
  };
26
+ var namespaceKv = (store, spaceId) => prefixKv(store, spaceScope(spaceId));
25
27
  /**
26
28
  * Every key a space's store holds, as patterns over the adapter's keys (`createKvStore`'s `prefix`, `kv:` unless it was
27
29
  * given another) — for whoever has to let all of them go at once: a space that is deleted, or reset to how it started.
28
30
  */
29
31
  var spaceKvPatterns = (spaceId, prefix = "kv:") => [`${prefix}${spaceScope(spaceId)}*`, `${prefix}${LIST_SEGMENT}${spaceScope(spaceId)}*`];
30
32
  //#endregion
31
- export { namespaceKv, spaceKvPatterns };
33
+ export { namespaceKv, prefixKv, spaceKvPatterns };
@@ -9,7 +9,8 @@ import { ActionLookups } from '../types';
9
9
  * page still learns nothing about what happens on the other side.
10
10
  *
11
11
  * Its input is the page's own context — route params, then query params — plus whatever the element declares, so
12
- * an action feeding `/blog/:slug` reads `{{ input.slug }}` and needs nothing else. The action's own input contract
13
- * still drops everything it did not declare.
12
+ * an action feeding `/blog/:slug` reads `{{ input.slug }}` and needs nothing else; a refresh's own params (what
13
+ * `reloadApi` or a bound `input` asked for) come last and win. The action's own input contract still drops everything
14
+ * it did not declare.
14
15
  */
15
16
  export declare const createActionResolver: (lookups: ActionLookups, module: ActionsModule) => RscElementResolver;
@@ -35,8 +35,9 @@ var shareKey = (parts) => createHash("sha256").update(JSON.stringify(parts)).dig
35
35
  * page still learns nothing about what happens on the other side.
36
36
  *
37
37
  * Its input is the page's own context — route params, then query params — plus whatever the element declares, so
38
- * an action feeding `/blog/:slug` reads `{{ input.slug }}` and needs nothing else. The action's own input contract
39
- * still drops everything it did not declare.
38
+ * an action feeding `/blog/:slug` reads `{{ input.slug }}` and needs nothing else; a refresh's own params (what
39
+ * `reloadApi` or a bound `input` asked for) come last and win. The action's own input contract still drops everything
40
+ * it did not declare.
40
41
  */
41
42
  var createActionResolver = (lookups, module) => {
42
43
  const share = createRenderShare();
@@ -59,7 +60,8 @@ var createActionResolver = (lookups, module) => {
59
60
  const values = {
60
61
  ...routeParams,
61
62
  ...queryParams,
62
- ...input
63
+ ...input,
64
+ ...req.ctx.rscParams
63
65
  };
64
66
  const trigger = findTriggerNode(entry.document.nodes, "render");
65
67
  const ttlMs = trigger ? triggerCacheMs(triggerParams(trigger)) : 0;
@@ -73,7 +75,8 @@ var createActionResolver = (lookups, module) => {
73
75
  ]);
74
76
  const callerId = user ? `user:${user.id}` : "render";
75
77
  let ownRunId;
76
- const startRun = async () => {
78
+ /** `stop` is the shared run's: aborted once nobody is waiting for it, this render included. */
79
+ const startRun = async (stop) => {
77
80
  /**
78
81
  * A key of its own per render, so two visitors are never each other's duplicate.
79
82
  *
@@ -93,12 +96,13 @@ var createActionResolver = (lookups, module) => {
93
96
  });
94
97
  ownRunId = run.runId;
95
98
  /**
96
- * The render giving up ends the run, not just the wait for it.
99
+ * The renders giving up end the run, not just the wait for it — all of them: a render another visitor joined is
100
+ * that visitor's answer too.
97
101
  *
98
- * `resolveRscData` stops waiting when an element's budget is gone, and before this the run carried on to
99
- * its own timeout — holding a slot and an outbound connection for a page that had already been answered.
102
+ * `resolveRscData` stops waiting when an element's budget is gone or the browser hung up, and before this the
103
+ * run carried on to its own timeout — holding a slot and an outbound connection for a page already answered.
100
104
  */
101
- const releaseRenderStop = onAbort(signal, () => run.controller.abort());
105
+ const releaseRenderStop = onAbort(stop, () => run.controller.abort());
102
106
  const startedAt = Date.now();
103
107
  const summaryOf = (outcome) => ({
104
108
  actionId: entry.id,
@@ -195,7 +199,7 @@ var createActionResolver = (lookups, module) => {
195
199
  * reads all in the same instant.
196
200
  */
197
201
  try {
198
- const { output, summary } = await share.run(key, ttlMs, startRun);
202
+ const { output, summary } = await share.run(key, ttlMs, startRun, signal);
199
203
  record(summary);
200
204
  return output;
201
205
  } catch (error) {
@@ -1,28 +1,11 @@
1
- /**
2
- * One answer for the visitors asking the same question at the same moment — and, when the author says so, for the
3
- * ones asking it a moment later.
4
- *
5
- * A `render` is a READ repeated once per visitor. A thousand people opening one page is a thousand runs of the
6
- * same flow and a thousand outbound requests to whatever it reads, all in flight together, all computing the same
7
- * thing. Refusing them is the wrong answer — that is what a per-space cap did, and it broke the page that was
8
- * doing well. Sharing is the right one.
9
- *
10
- * Two behaviours, and only one of them is a cache:
11
- *
12
- * - **In flight**: a render that arrives while an identical one is running joins it. Always on, and it cannot
13
- * serve anything stale — the answer is being computed right now, for this request as much as for the first.
14
- * - **Reuse**: the answer is kept for as long as the trigger's `cacheSeconds` says. Off unless authored, because
15
- * only the author knows whether their page may repeat itself.
16
- *
17
- * A failure is never kept. The joiners of a failed run fail with it — they would have failed too — but the next
18
- * request tries again rather than being told for a minute about a request that was already over.
19
- *
20
- * In-process, and that is honest here in a way it is not for a lock: two replicas each keeping their own copy is
21
- * two computations instead of one, while two replicas each keeping their own LOCK is no lock at all.
22
- */
23
1
  export type RenderShare = {
24
- /** Runs `produce`, or joins whatever is already producing the same key. `ttlMs` of 0 keeps nothing afterwards. */
25
- run: (key: string, ttlMs: number, produce: () => Promise<unknown>) => Promise<unknown>;
2
+ /**
3
+ * Runs `produce`, or joins whatever is already producing the same key. `ttlMs` of 0 keeps nothing afterwards.
4
+ *
5
+ * `signal` is this caller's: aborted, the caller stops waiting — and the run is stopped (`produce`'s own signal)
6
+ * only once every caller waiting on it has. One visitor who gave up must not cost the others their answer.
7
+ */
8
+ run: (key: string, ttlMs: number, produce: (signal: AbortSignal) => Promise<unknown>, signal?: AbortSignal) => Promise<unknown>;
26
9
  /** Entries currently held, for a deployment that wants to see it. */
27
10
  size: () => number;
28
11
  };
@@ -1,3 +1,4 @@
1
+ import onAbort from "../../../helpers/onAbort.js";
1
2
  //#region src/modules/actions/runtime/renderShare.ts
2
3
  /** Keeps the map from growing without bound on a space with many keys: expired entries are dropped on write. */
3
4
  var sweep = (entries, now) => {
@@ -5,27 +6,60 @@ var sweep = (entries, now) => {
5
6
  if (!entry.inFlight && (entry.expiresAt === void 0 || entry.expiresAt <= now)) entries.delete(key);
6
7
  });
7
8
  };
9
+ /** What a caller that stopped waiting is answered with: its own end, not the run's. */
10
+ var stoppedWaiting = () => new DOMException("The render was aborted: its page stopped waiting for it", "AbortError");
11
+ /** One caller waiting on a run in flight, until it answers or the caller gives up — whichever comes first. */
12
+ var wait = (entry, inFlight, signal) => {
13
+ entry.waiting += 1;
14
+ let waiting = true;
15
+ const leave = () => {
16
+ if (!waiting) return;
17
+ waiting = false;
18
+ entry.waiting -= 1;
19
+ if (entry.waiting === 0) entry.controller?.abort();
20
+ };
21
+ return new Promise((resolve, reject) => {
22
+ const release = onAbort(signal, () => {
23
+ leave();
24
+ reject(stoppedWaiting());
25
+ });
26
+ inFlight.then((value) => {
27
+ waiting = false;
28
+ release();
29
+ resolve(value);
30
+ }, (error) => {
31
+ waiting = false;
32
+ release();
33
+ reject(error instanceof Error ? error : new Error(String(error)));
34
+ });
35
+ });
36
+ };
8
37
  var createRenderShare = (now = Date.now) => {
9
38
  const entries = /* @__PURE__ */ new Map();
10
- const run = async (key, ttlMs, produce) => {
39
+ const run = async (key, ttlMs, produce, signal) => {
11
40
  const existing = entries.get(key);
12
- if (existing?.inFlight) return existing.inFlight;
41
+ if (existing?.inFlight) return wait(existing, existing.inFlight, signal);
13
42
  if (existing && existing.expiresAt !== void 0 && existing.expiresAt > now()) return existing.value;
14
- const inFlight = produce();
15
- entries.set(key, { inFlight });
16
- try {
17
- const value = await inFlight;
43
+ const controller = new AbortController();
44
+ const inFlight = produce(controller.signal);
45
+ const entry = {
46
+ inFlight,
47
+ controller,
48
+ waiting: 0
49
+ };
50
+ entries.set(key, entry);
51
+ inFlight.then((value) => {
18
52
  sweep(entries, now());
19
53
  if (ttlMs > 0) entries.set(key, {
20
54
  value,
21
- expiresAt: now() + ttlMs
55
+ expiresAt: now() + ttlMs,
56
+ waiting: 0
22
57
  });
23
- else entries.delete(key);
24
- return value;
25
- } catch (error) {
26
- entries.delete(key);
27
- throw error;
28
- }
58
+ else if (entries.get(key) === entry) entries.delete(key);
59
+ }, () => {
60
+ if (entries.get(key) === entry) entries.delete(key);
61
+ });
62
+ return wait(entry, inFlight, signal);
29
63
  };
30
64
  return {
31
65
  run,
@@ -224,7 +224,12 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
224
224
  redactor,
225
225
  signing
226
226
  }, request, signal, runFetch);
227
- const buildContext = contextFor(controller.signal, createRunFetch(baseFetch, controller.signal, limits, lineage));
227
+ /** The request budget ran out: said on the server's log at once, and in the step that hit it. */
228
+ let budgetSpent;
229
+ const buildContext = contextFor(controller.signal, createRunFetch(baseFetch, controller.signal, limits, lineage, (message) => {
230
+ budgetSpent = message;
231
+ serverLog.warn("Actions", `"${entry.id}" (run ${runId}): ${message}`);
232
+ }));
228
233
  const trace = [];
229
234
  const steps = [];
230
235
  const startedAt = Date.now();
@@ -285,12 +290,20 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
285
290
  const log = (line) => {
286
291
  if (logs.length < MAX_LOG_LINES) logs.push(redact(line.length > MAX_LOG_LINE ? `${line.slice(0, MAX_LOG_LINE)}…` : line));
287
292
  };
293
+ /** The step that spent the budget says so, whether or not it caught the refusal. */
294
+ const noteBudget = () => {
295
+ if (budgetSpent) {
296
+ log(budgetSpent);
297
+ budgetSpent = void 0;
298
+ }
299
+ };
288
300
  try {
289
301
  const outcome = await runNode(node, scope, registry, (stepScope) => ({
290
302
  ...build(stepScope),
291
303
  log
292
304
  }));
293
305
  if (late()) return outcome;
306
+ noteBudget();
294
307
  const endTime = Date.now();
295
308
  trace.push({
296
309
  node,
@@ -312,6 +325,7 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
312
325
  return outcome;
313
326
  } catch (error) {
314
327
  if (late()) throw error;
328
+ noteBudget();
315
329
  const endTime = Date.now();
316
330
  const message = redact(error instanceof Error ? error.message : String(error));
317
331
  trace.push({
@@ -18,6 +18,21 @@ var coerce = (type, value) => {
18
18
  default: return typeof value === "object" && value !== null ? void 0 : String(value);
19
19
  }
20
20
  };
21
+ /** What a value is, in the words a refusal uses for it. */
22
+ var kindOf = (value) => {
23
+ if (Array.isArray(value)) return "a list";
24
+ if (typeof value === "object" && value !== null) return "an object";
25
+ return typeof value === "string" ? "text that is not one" : `a ${typeof value}`;
26
+ };
27
+ /**
28
+ * Why a value does not fit its field, said the way the fix is written.
29
+ *
30
+ * A list or an object sent to a `text` field is nearly always JSON the page meant as text — a step param that is only a
31
+ * `|json_encode` is the value it encodes — so the refusal says which type takes it.
32
+ */
33
+ var misfit = (key, type, value) => {
34
+ return `${key} (${type}, got ${kindOf(value)}${type === "text" && typeof value === "object" && value !== null ? "; declare the field `json` to take it" : ""})`;
35
+ };
21
36
  /**
22
37
  * Coerces a value bag against declared fields.
23
38
  *
@@ -37,7 +52,7 @@ var applyFields = (fields, raw) => {
37
52
  }
38
53
  const coerced = coerce(field.type, provided);
39
54
  if (coerced === void 0) {
40
- invalid.push(key);
55
+ invalid.push(misfit(key, field.type, provided));
41
56
  return;
42
57
  }
43
58
  values[key] = coerced;
@@ -1,4 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
+ //#region src/modules/actions/runtime/signing.ts
3
+ /** A signing secret is what every link and key a space hands out stands on: as long as a 256-bit key written out. */
4
+ var MIN_SIGNING_SECRET_LENGTH = 32;
2
5
  /**
3
6
  * Signing for every space, from the one secret the deployment holds (`signingSecret`).
4
7
  *
@@ -22,4 +25,4 @@ var createSigning = (secret) => {
22
25
  };
23
26
  };
24
27
  //#endregion
25
- export { createSigning };
28
+ export { MIN_SIGNING_SECRET_LENGTH, createSigning };
@@ -14,7 +14,13 @@ import { ActionKvStore, ActionRunRequest, ActionsConfig, ActionTaskContext, Reso
14
14
  * Every ceiling lives HERE rather than in each task, because this is the only door a task has to the outside
15
15
  * world — a task that had to remember to count its own bytes is a task that will forget.
16
16
  */
17
- export declare const createRunFetch: (base: typeof fetch, signal: AbortSignal, limits: ResolvedActionLimits, lineage: string[]) => typeof fetch;
17
+ export declare const createRunFetch: (base: typeof fetch, signal: AbortSignal, limits: ResolvedActionLimits, lineage: string[],
18
+ /**
19
+ * Told once, the first time a request is refused for the budget. A task reading many sources usually catches a
20
+ * failed fetch and goes on with the rest, which made the budget invisible: twenty sources answered, the rest
21
+ * "failed" like a network error, and nothing anywhere named the limit.
22
+ */
23
+ onBudgetSpent?: (message: string) => void) => typeof fetch;
18
24
  /** What a task's context is made of that outlives one run: the server's stores and the run's redactor. */
19
25
  export type TaskContextDeps = {
20
26
  kv: ActionKvStore;
@@ -42,11 +42,15 @@ var capped = (response, maxBytes) => {
42
42
  * Every ceiling lives HERE rather than in each task, because this is the only door a task has to the outside
43
43
  * world — a task that had to remember to count its own bytes is a task that will forget.
44
44
  */
45
- var createRunFetch = (base, signal, limits, lineage) => {
45
+ var createRunFetch = (base, signal, limits, lineage, onBudgetSpent) => {
46
46
  let issued = 0;
47
47
  return async (input, init) => {
48
48
  issued += 1;
49
- if (issued > limits.maxRequests) throw new ActionRunError("over_capacity", `Action exceeded its ${limits.maxRequests} outbound request budget`);
49
+ if (issued > limits.maxRequests) {
50
+ const message = `Action exceeded its ${limits.maxRequests} outbound request budget — request ${issued} and every one after it were refused. A deployment that needs more raises it: \`createServer({ action: { limits: { maxRequests } } })\`.`;
51
+ if (issued === limits.maxRequests + 1) onBudgetSpent?.(message);
52
+ throw new ActionRunError("over_capacity", message);
53
+ }
50
54
  const headers = new Headers(init?.headers);
51
55
  headers.set("X-Plitzi-Action-Lineage", lineage.join(","));
52
56
  return capped(await base(input, {
@@ -13,6 +13,8 @@ export declare const taskNameProblem: (task: Pick<ActionTask, "namespace" | "act
13
13
  export type TaskRegistryOptions = {
14
14
  /** At least one database driver is registered, so `db.query` has an engine to run against. */
15
15
  db?: boolean;
16
+ /** The server halves of the plugins this server ships, by plugin type: their tasks are `<type>.<action>`. */
17
+ plugins?: Readonly<Record<string, FunctionsDefinition>>;
16
18
  };
17
19
  /**
18
20
  * Builds the set of tasks this server can run.
@@ -21,4 +23,4 @@ export type TaskRegistryOptions = {
21
23
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
22
24
  * in that deployment does. Both are invisible until a run misbehaves in production.
23
25
  */
24
- export declare const createTaskRegistry: (functions?: readonly FunctionsDefinition[], { db }?: TaskRegistryOptions) => ActionTaskRegistry;
26
+ export declare const createTaskRegistry: (functions?: readonly FunctionsDefinition[], { db, plugins }?: TaskRegistryOptions) => ActionTaskRegistry;
@@ -1,5 +1,6 @@
1
1
  import { builtinTasks } from "./builtins.js";
2
2
  import { dbTasks } from "./db.js";
3
+ import { pluginTaskProblem } from "../../functions/scope.js";
3
4
  import { nativeTasks } from "../../functions/native.js";
4
5
  //#region src/modules/actions/tasks/registry.ts
5
6
  var NAME_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
@@ -22,7 +23,7 @@ var taskNameProblem = (task, reserved = RESERVED_NAMESPACES) => {
22
23
  * name is unreachable from any document, and one shadowing a built-in silently changes what every existing action
23
24
  * in that deployment does. Both are invisible until a run misbehaves in production.
24
25
  */
25
- var createTaskRegistry = (functions = [], { db = false } = {}) => {
26
+ var createTaskRegistry = (functions = [], { db = false, plugins = {} } = {}) => {
26
27
  const tasks = /* @__PURE__ */ new Map();
27
28
  (db ? [...builtinTasks, ...dbTasks] : builtinTasks).forEach((task) => tasks.set(taskName(task), {
28
29
  ...task,
@@ -40,6 +41,19 @@ var createTaskRegistry = (functions = [], { db = false } = {}) => {
40
41
  origin: "deployment"
41
42
  });
42
43
  });
44
+ Object.entries(plugins).forEach(([plugin, definition]) => {
45
+ nativeTasks([definition], { plugin }).forEach((task) => {
46
+ const problem = taskNameProblem(task) ?? (task.namespace === plugin ? void 0 : pluginTaskProblem(plugin, task.namespace, task.action));
47
+ if (problem) throw new Error(`[Actions] ${problem}`);
48
+ const name = taskName(task);
49
+ if (tasks.has(name)) throw new Error(`[Actions] Task "${name}" is registered twice`);
50
+ tasks.set(name, {
51
+ ...task,
52
+ name,
53
+ origin: "plugin"
54
+ });
55
+ });
56
+ });
43
57
  return {
44
58
  get: (name) => tasks.get(name),
45
59
  list: () => [...tasks.values()]
@@ -117,9 +117,21 @@ export type ActionLookups = {
117
117
  /**
118
118
  * The space's own functions as of that revision — the bundle and what it declared when saved, run by the deployment's
119
119
  * sandbox (`functions.runner`), or by the space's own runtime when it answers one with a `runner` of its own. Only
120
- * asked when a run names a task the deployment does not have, or a request a path under `/api/`.
120
+ * asked when a run names a task the deployment does not have, or a request a path under `/fn/`.
121
121
  */
122
122
  getFunctions?: (spaceId: number, at?: SpaceRevision) => Promise<SpaceFunctions | undefined>;
123
+ /**
124
+ * The server halves of the plugins the space uses, as of that revision, by plugin type — each built and checked as a
125
+ * plugin's (`prepareFunctions` with its `scope`) and run in the same sandbox as the space's own, with a plugin's
126
+ * narrower `ctx`. Asked like `getFunctions`: for a task the deployment does not have, or a path under
127
+ * `/fn/plugins/<type>/`.
128
+ */
129
+ getPluginFunctions?: (spaceId: number, at?: SpaceRevision) => Promise<Record<string, SpaceFunctions> | undefined>;
130
+ /**
131
+ * The space's own data as of that revision: its files' text by path (`products.json`), for a provider resolved on the
132
+ * server whose `query` is `/data/<file>` — the platform's `dataDir`. Absent, such a provider reads nothing here.
133
+ */
134
+ getData?: (spaceId: number, at?: SpaceRevision) => Promise<Record<string, string> | undefined>;
123
135
  /**
124
136
  * The feature flags the space declares as of that revision, for a flow that reads `{{ flags.<name> }}`. Only asked
125
137
  * for a run whose document names `flags` at all. Absent, such a flow sees no flags — and the server says so.
@@ -228,7 +240,8 @@ export type ActionTask<T extends Record<string, unknown> = Record<string, unknow
228
240
  * Whose task it is: the deployment's — shipped with the server, or its own native functions — or the space's, from the
229
241
  * functions it saved. The run treats both alike; an editor lists them apart, as the platform's steps and the space's own.
230
242
  */
231
- export type ActionTaskOrigin = 'deployment' | 'space';
243
+ /** Whose step it is: the deployment's, the space's own functions', or the server half of a plugin the space uses. */
244
+ export type ActionTaskOrigin = 'deployment' | 'space' | 'plugin';
232
245
  export type RegisteredTask = ActionTask<Record<string, unknown>> & {
233
246
  name: string;
234
247
  origin: ActionTaskOrigin;
@@ -14,6 +14,12 @@ export type FunctionUsageRecord = {
14
14
  */
15
15
  export type FunctionsConfig = {
16
16
  native?: FunctionsDefinition[];
17
+ /**
18
+ * The server halves of the plugins this server ships, by plugin type — loaded natively like `native`, but each with a
19
+ * plugin's narrower `ctx` (its own `kv` and limits, signatures only it verifies, none of the space's credentials or
20
+ * channels). Their tasks are `<type>.<action>`, their routes answer under `/fn/plugins/<type>/`.
21
+ */
22
+ plugins?: Record<string, FunctionsDefinition>;
17
23
  /**
18
24
  * Where the spaces' own functions run: `createRemoteRunner` for the runner service, a provider's adapter, or
19
25
  * `createIsolateRunner` only where this process holds nothing a space must not reach. Absent, spaces have no
@@ -1,4 +1,5 @@
1
1
  import { FunctionContext } from './contract';
2
+ import { FunctionScope } from './scope';
2
3
  import { ActionTaskContext } from '../actions/types';
3
4
  /**
4
5
  * Whether `hostname` is one the space declared: exactly, or under a `*.` wildcard (`*.example.com` is every subdomain of
@@ -12,6 +13,7 @@ export declare const hostAllowed: (hostname: string, hosts: readonly string[]) =
12
13
  export declare const lineOf: (values: readonly unknown[]) => string;
13
14
  /**
14
15
  * What a space's code is handed, built from the run's own context: the one place a {@link FunctionContext} is made.
15
- * A self-hosted server hands it to the code directly; the platform answers the sandbox's calls with it.
16
+ * A self-hosted server hands it to the code directly; the platform answers the sandbox's calls with it. With a `scope`,
17
+ * it is a plugin's code, and gets the plugin's corner of the space (`./scope`).
16
18
  */
17
- export declare const functionContextFor: (ctx: ActionTaskContext, hosts: readonly string[]) => FunctionContext;
19
+ export declare const functionContextFor: (ctx: ActionTaskContext, hosts: readonly string[], scope?: FunctionScope) => FunctionContext;