@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.
- package/CHANGELOG.md +350 -0
- package/README.md +126 -17
- package/dist/actions.d.ts +8 -5
- package/dist/actions.js +3 -1
- package/dist/adapters/jsonAdapters.d.ts +1 -1
- package/dist/adapters/jsonAdapters.js +16 -3
- package/dist/core/configSeam.js +20 -4
- package/dist/core/createServer.js +3 -2
- package/dist/core/handlers/socialRouteHandlers.js +6 -1
- package/dist/core/http/stages/devReload.d.ts +11 -0
- package/dist/core/http/stages/devReload.js +17 -0
- package/dist/core/server/baseServer.d.ts +2 -0
- package/dist/core/server/baseServer.js +3 -0
- package/dist/core/server/pageServer.js +18 -0
- package/dist/core/services/registry.js +2 -0
- package/dist/core/services/rsc.js +1 -1
- package/dist/core/services/spaceDocument.d.ts +2 -0
- package/dist/core/services/spaceDocument.js +11 -0
- package/dist/functions-api.d.ts +6 -1
- package/dist/functionsRunner.d.ts +5 -0
- package/dist/functionsRunner.js +2 -1
- package/dist/helpers/hydrationPayload.d.ts +25 -4
- package/dist/helpers/hydrationPayload.js +36 -30
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/modules/actions/index.d.ts +11 -2
- package/dist/modules/actions/index.js +29 -7
- package/dist/modules/actions/runtime/fileKv.d.ts +28 -0
- package/dist/modules/actions/runtime/fileKv.js +95 -0
- package/dist/modules/actions/runtime/fileKv.test.d.ts +1 -0
- package/dist/modules/actions/runtime/memoryKv.d.ts +23 -0
- package/dist/modules/actions/runtime/memoryKv.js +39 -24
- package/dist/modules/actions/runtime/namespaceKv.d.ts +2 -0
- package/dist/modules/actions/runtime/namespaceKv.js +5 -3
- package/dist/modules/actions/runtime/renderResolver.d.ts +3 -2
- package/dist/modules/actions/runtime/renderResolver.js +13 -9
- package/dist/modules/actions/runtime/renderShare.d.ts +7 -24
- package/dist/modules/actions/runtime/renderShare.js +47 -13
- package/dist/modules/actions/runtime/runAction.js +15 -1
- package/dist/modules/actions/runtime/scope.js +16 -1
- package/dist/modules/actions/runtime/signing.js +4 -1
- package/dist/modules/actions/runtime/taskContext.d.ts +7 -1
- package/dist/modules/actions/runtime/taskContext.js +6 -2
- package/dist/modules/actions/tasks/registry.d.ts +3 -1
- package/dist/modules/actions/tasks/registry.js +15 -1
- package/dist/modules/actions/types.d.ts +15 -2
- package/dist/modules/functions/config.d.ts +6 -0
- package/dist/modules/functions/context.d.ts +4 -2
- package/dist/modules/functions/context.js +43 -21
- package/dist/modules/functions/load.d.ts +8 -1
- package/dist/modules/functions/load.js +8 -4
- package/dist/modules/functions/manifest.d.ts +4 -1
- package/dist/modules/functions/manifest.js +14 -2
- package/dist/modules/functions/native.d.ts +2 -1
- package/dist/modules/functions/native.js +2 -2
- package/dist/modules/functions/plugins.test.d.ts +1 -0
- package/dist/modules/functions/routes.d.ts +8 -3
- package/dist/modules/functions/routes.js +41 -21
- package/dist/modules/functions/scope.d.ts +30 -0
- package/dist/modules/functions/scope.js +28 -0
- package/dist/modules/functions/space.d.ts +10 -5
- package/dist/modules/functions/space.js +37 -15
- package/dist/modules/rsc/connectorRscData.d.ts +3 -1
- package/dist/modules/rsc/connectorRscData.js +8 -4
- package/dist/modules/rsc/handler.d.ts +3 -16
- package/dist/modules/rsc/handler.js +10 -4
- package/dist/modules/rsc/publicFileResolver.d.ts +15 -12
- package/dist/modules/rsc/publicFileResolver.js +56 -24
- package/dist/modules/rsc/resolveRscData.d.ts +3 -1
- package/dist/modules/rsc/resolveRscData.js +8 -5
- package/dist/modules/runtime/bundle.d.ts +5 -0
- package/dist/modules/runtime/bundle.js +20 -5
- package/dist/modules/sqlite/kv.d.ts +34 -0
- package/dist/modules/sqlite/kv.js +99 -0
- package/dist/modules/sqlite/kv.test.d.ts +1 -0
- package/dist/modules/ssr/Component.d.ts +2 -1
- package/dist/modules/ssr/Component.js +2 -1
- package/dist/modules/ssr/prepareRender.js +48 -6
- package/dist/modules/ssr/spaceDocument.d.ts +25 -0
- package/dist/modules/ssr/spaceDocument.js +72 -0
- package/dist/modules/ssr/spaceDocument.test.d.ts +1 -0
- package/dist/modules/ssr/testing/offlineData.d.ts +5 -0
- package/dist/modules/ssr/views/template.ejs +122 -20
- package/dist/plugins/compile.js +6 -21
- package/dist/plugins/compile.test.d.ts +1 -0
- package/dist/plugins/copy.d.ts +5 -0
- package/dist/plugins/copy.js +16 -1
- package/dist/plugins/copy.test.d.ts +1 -0
- package/dist/plugins/manager.d.ts +13 -0
- package/dist/plugins/manager.js +40 -11
- package/dist/plugins/watch.d.ts +12 -0
- package/dist/plugins/watch.js +79 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +2 -2
- package/dist/sqlite.d.ts +17 -0
- package/dist/sqlite.js +2 -0
- package/package.json +8 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
- package/skills/plitzi-authoring/SKILL.md +20 -19
- package/skills/plitzi-authoring/recipes/plugin.ts +1 -1
- package/skills/plitzi-authoring/recipes/server-data.ts +3 -3
- package/skills/plitzi-authoring/recipes/show-data.ts +3 -2
- package/skills/plitzi-authoring/recipes/typed-data.ts +1 -1
- package/skills/plitzi-authoring/reference/authoring-errors.md +8 -2
- package/skills/plitzi-authoring/reference/colours-and-motion.md +3 -1
- package/skills/plitzi-authoring/reference/data-and-visibility.md +24 -22
- package/skills/plitzi-authoring/reference/drawing.md +24 -0
- package/skills/plitzi-authoring/reference/efficiency.md +19 -2
- package/skills/plitzi-authoring/reference/flows.md +3 -0
- package/skills/plitzi-authoring/reference/layouts.md +8 -23
- package/skills/plitzi-authoring/reference/performance.md +2 -2
- package/skills/plitzi-authoring/reference/plugins.md +51 -38
- package/skills/plitzi-authoring/reference/review-checklist.md +2 -0
- package/skills/plitzi-authoring/reference/structure.md +32 -1
- package/skills/plitzi-authoring/reference/templates.md +5 -2
- package/skills/plitzi-authoring/reference/testing.md +9 -2
- package/skills/plitzi-authoring/reference/typed-sources.md +3 -1
- 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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
13
|
-
*
|
|
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
|
|
39
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
99
|
-
* its own timeout — holding a slot and an outbound connection for a page
|
|
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(
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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[]
|
|
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)
|
|
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 `/
|
|
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
|
-
|
|
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;
|