@plitzi/sdk-server 0.38.10 → 0.38.12
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 +287 -0
- package/README.md +3 -3
- package/dist/auth.js +1 -1
- package/dist/core/auth/createAuth.js +1 -1
- package/dist/core/http/stages/signInRoute.js +1 -1
- package/dist/core/server/baseServer.js +11 -6
- package/dist/core/server/fleet/stores.d.ts +7 -0
- package/dist/core/server/fleet/stores.js +16 -2
- package/dist/core/server/fleet/supervisor.d.ts +2 -0
- package/dist/core/server/fleet/supervisor.js +14 -2
- package/dist/core/server/pageServer.js +1 -1
- package/dist/functions-api.d.ts +47 -2
- package/dist/helpers/cache/cacheManager.d.ts +9 -2
- package/dist/helpers/cache/cacheManager.js +46 -22
- package/dist/helpers/cache/keys.d.ts +12 -5
- package/dist/helpers/cache/keys.js +56 -11
- package/dist/modules/actions/index.js +6 -2
- package/dist/modules/actions/jobs/later.d.ts +43 -0
- package/dist/modules/actions/jobs/later.js +66 -0
- package/dist/modules/actions/jobs/later.test.d.ts +1 -0
- package/dist/modules/actions/jobs/memoryQueue.js +14 -1
- package/dist/modules/actions/jobs/worker.js +2 -2
- package/dist/modules/actions/runtime/credentials.d.ts +10 -0
- package/dist/modules/actions/runtime/credentials.js +28 -0
- package/dist/modules/actions/runtime/credentials.test.d.ts +1 -0
- package/dist/modules/actions/runtime/fileKv.d.ts +8 -3
- package/dist/modules/actions/runtime/fileKv.js +79 -7
- package/dist/modules/actions/runtime/precheck.js +1 -1
- package/dist/modules/actions/runtime/rateLimit.d.ts +7 -0
- package/dist/modules/actions/runtime/rateLimit.js +7 -1
- package/dist/modules/actions/runtime/runAction.d.ts +4 -1
- package/dist/modules/actions/runtime/runAction.js +46 -11
- package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
- package/dist/modules/actions/runtime/taskContext.js +7 -1
- package/dist/modules/actions/tasks/flow.js +113 -35
- package/dist/modules/actions/types.d.ts +8 -0
- package/dist/modules/functions/build.js +1 -1
- package/dist/modules/functions/capabilities.js +21 -0
- package/dist/modules/functions/context.js +9 -3
- package/dist/modules/functions/contract.d.ts +10 -0
- package/dist/modules/functions/driver.js +16 -4
- package/dist/modules/functions/local.d.ts +1 -1
- package/dist/modules/functions/local.js +2 -1
- package/dist/modules/functions/manifest.js +8 -3
- package/dist/modules/functions/protocol.d.ts +7 -0
- package/dist/modules/mongo/jobQueue.js +26 -0
- package/dist/modules/mysql/jobs/jobQueue.js +21 -2
- package/dist/modules/mysql/jobs/schema.d.ts +3 -2
- package/dist/modules/mysql/jobs/schema.js +24 -11
- package/dist/modules/project/plugins.d.ts +1 -1
- package/dist/modules/project/serveProject.js +29 -4
- package/dist/modules/realtime/handlers.d.ts +4 -1
- package/dist/modules/realtime/handlers.js +8 -4
- package/dist/modules/realtime/hub.d.ts +3 -1
- package/dist/modules/realtime/hub.js +8 -4
- package/dist/modules/ssr/prepareRender.js +1 -1
- package/package.json +3 -3
- package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
- package/skills/plitzi-authoring/recipes/plugin.ts +9 -7
- package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
- package/skills/plitzi-authoring/reference/accessibility.md +1 -0
- package/skills/plitzi-authoring/reference/authoring-errors.md +8 -4
- package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
- package/skills/plitzi-authoring/reference/elements-and-styles.md +1 -1
- package/skills/plitzi-authoring/reference/flows.md +6 -5
- package/skills/plitzi-authoring/reference/layouts.md +1 -0
- package/skills/plitzi-authoring/reference/lists.md +1 -0
- package/skills/plitzi-authoring/reference/plugins.md +43 -37
- package/skills/plitzi-authoring/reference/realtime.md +4 -1
- package/skills/plitzi-authoring/reference/rules.md +1 -1
- package/skills/plitzi-authoring/reference/typed-sources.md +6 -4
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { serverLog } from "../../../helpers/serverLog.js";
|
|
2
|
-
import
|
|
2
|
+
import "../jobs/schedules.js";
|
|
3
|
+
import { ActionRefusal, ActionRunError, isActionRefusal } from "./errors.js";
|
|
3
4
|
import { runCancelKey } from "./guards.js";
|
|
4
5
|
import { createKvStore } from "./kvStore.js";
|
|
5
6
|
import { resolveLimits } from "./limits.js";
|
|
@@ -150,10 +151,12 @@ var MAX_LOG_LINES = 100;
|
|
|
150
151
|
var MAX_LOG_LINE = 1e3;
|
|
151
152
|
/**
|
|
152
153
|
* A value written as text, read as the type its task declares: a `number` param written `5` — or bound to text that says
|
|
153
|
-
* it — arrives as 5, a `boolean` one written `'true'` as `true`, an empty number as nothing (so the default applies)
|
|
154
|
-
*
|
|
154
|
+
* it — arrives as 5, a `boolean` one written `'true'` as `true`, an empty number as nothing (so the default applies), and
|
|
155
|
+
* a `text` one bound to a number as its digits. The browser reads a callback's params the same way
|
|
156
|
+
* (`coerceDeclaredParams`): a task's author never writes `Number(…)` or `String(…)`.
|
|
155
157
|
*/
|
|
156
158
|
var asDeclared = (type, value) => {
|
|
159
|
+
if (typeof value === "number" && (type === "text" || type === "textarea")) return String(value);
|
|
157
160
|
if (typeof value !== "string") return value;
|
|
158
161
|
if (type === "number") {
|
|
159
162
|
const parsed = Number(value.trim());
|
|
@@ -161,11 +164,36 @@ var asDeclared = (type, value) => {
|
|
|
161
164
|
}
|
|
162
165
|
return type === "boolean" && (value === "true" || value === "false") ? value === "true" : value;
|
|
163
166
|
};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Why a value does not fit the rules its param declares — `required`, a text's `maxLength`, a number's `min` and `max` —
|
|
169
|
+
* in the words the page shows: what a task's code otherwise checked by hand before doing anything. A param that
|
|
170
|
+
* declares none is never judged.
|
|
171
|
+
*/
|
|
172
|
+
var paramProblem = (key, param, value) => {
|
|
173
|
+
const name = param.label ?? key;
|
|
174
|
+
if (value === void 0 || value === null || typeof value === "string" && value.trim() === "") {
|
|
175
|
+
const defaulted = param.defaultValue !== void 0 && param.defaultValue !== "";
|
|
176
|
+
return param.required && !defaulted ? `${name} is required` : void 0;
|
|
177
|
+
}
|
|
178
|
+
if ((param.type === "text" || param.type === "textarea") && param.maxLength !== void 0) return typeof value === "string" && value.length > param.maxLength ? `${name} is longer than ${String(param.maxLength)} characters` : void 0;
|
|
179
|
+
if (param.type === "number" && (param.min !== void 0 || param.max !== void 0)) {
|
|
180
|
+
if (typeof value !== "number") return `${name} is not a number`;
|
|
181
|
+
if (param.min !== void 0 && value < param.min) return `${name} is at least ${String(param.min)}`;
|
|
182
|
+
if (param.max !== void 0 && value > param.max) return `${name} is at most ${String(param.max)}`;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
var withDefaults = (task, params) => {
|
|
186
|
+
const problems = [];
|
|
187
|
+
const values = Object.entries(task.params).reduce((acum, [key, param]) => {
|
|
188
|
+
const value = asDeclared(typeof param.type === "function" ? param.type(acum) : param.type, acum[key]);
|
|
189
|
+
const problem = paramProblem(key, param, value);
|
|
190
|
+
if (problem) problems.push(problem);
|
|
191
|
+
acum[key] = value === void 0 || value === "" ? param.defaultValue ?? "" : value;
|
|
192
|
+
return acum;
|
|
193
|
+
}, { ...params });
|
|
194
|
+
if (problems.length > 0) throw new ActionRefusal(problems.join("; "));
|
|
195
|
+
return values;
|
|
196
|
+
};
|
|
169
197
|
var runNode = async (node, scope, registry, buildContext) => {
|
|
170
198
|
if (!node.action || !node.enabled) return {
|
|
171
199
|
status: "disabled",
|
|
@@ -194,13 +222,18 @@ var runNode = async (node, scope, registry, buildContext) => {
|
|
|
194
222
|
* input contract — lives HERE rather than in a transport, because a deployment mounting its own trigger calls this
|
|
195
223
|
* directly. A check in the endpoint would be a check a custom trigger silently skips.
|
|
196
224
|
*/
|
|
197
|
-
var createActionRunner = (config, registryFor, baseFetch = fetch) => {
|
|
225
|
+
var createActionRunner = (config, registryFor, baseFetch = fetch, jobsQueue = () => void 0) => {
|
|
198
226
|
const kv = createKvStore(config.kv ?? createMemoryKv());
|
|
199
227
|
const emailSender = createEmailSender({
|
|
200
228
|
...config.email,
|
|
201
229
|
kv
|
|
202
230
|
});
|
|
203
231
|
const signing = config.signingSecret ? createSigning(config.signingSecret) : void 0;
|
|
232
|
+
const later = {
|
|
233
|
+
queue: jobsQueue,
|
|
234
|
+
lookups: config.lookups,
|
|
235
|
+
maxAttempts: (config.jobs === false ? void 0 : config.jobs?.maxAttempts) ?? 3
|
|
236
|
+
};
|
|
204
237
|
/** Never allowed to fail a run: a logging outage must not take an action down, the same rule metering follows. */
|
|
205
238
|
const record = async (entry) => {
|
|
206
239
|
try {
|
|
@@ -236,7 +269,8 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
|
|
|
236
269
|
kv,
|
|
237
270
|
email: emailSender,
|
|
238
271
|
redactor,
|
|
239
|
-
signing
|
|
272
|
+
signing,
|
|
273
|
+
later
|
|
240
274
|
}, request, signal, runFetch);
|
|
241
275
|
/** The request budget ran out: said on the server's log at once, and in the step that hit it. */
|
|
242
276
|
let budgetSpent;
|
|
@@ -548,7 +582,8 @@ var createActionRunner = (config, registryFor, baseFetch = fetch) => {
|
|
|
548
582
|
kv,
|
|
549
583
|
email: emailSender,
|
|
550
584
|
redactor: createRedactor(),
|
|
551
|
-
signing
|
|
585
|
+
signing,
|
|
586
|
+
later
|
|
552
587
|
}, contextRequest, signal, createRunFetch(baseFetch, signal, resolveLimits(config.limits, void 0), lineage))({});
|
|
553
588
|
return {
|
|
554
589
|
runAction,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createEmailSender } from './email';
|
|
2
2
|
import { createRedactor } from './scope';
|
|
3
3
|
import { createSigning } from './signing';
|
|
4
|
+
import { LaterDeps } from '../jobs/later';
|
|
4
5
|
import { ActionKvStore, ActionRunRequest, ActionsConfig, ActionTaskContext, ResolvedActionLimits } from '../types';
|
|
5
6
|
/**
|
|
6
7
|
* Counts outbound calls, refuses past the budget, caps what one answer may carry back, and stamps the run's
|
|
@@ -28,6 +29,8 @@ export type TaskContextDeps = {
|
|
|
28
29
|
redactor: ReturnType<typeof createRedactor>;
|
|
29
30
|
/** Absent when the deployment gave no `signingSecret`: its spaces then sign nothing. */
|
|
30
31
|
signing?: ReturnType<typeof createSigning>;
|
|
32
|
+
/** What runs set for later go through: the module's queue, read when asked. */
|
|
33
|
+
later: LaterDeps;
|
|
31
34
|
};
|
|
32
35
|
/** Who and what the context is for — a run's request, or anything shaped like one (a function's route). */
|
|
33
36
|
export type TaskContextRequest = Pick<ActionRunRequest, 'runId' | 'spaceId' | 'environment' | 'trigger' | 'user' | 'callerId' | 'at' | 'emit'>;
|
|
@@ -36,4 +39,4 @@ export type TaskContextRequest = Pick<ActionRunRequest, 'runId' | 'spaceId' | 'e
|
|
|
36
39
|
* every secret it resolves registered with the redactor, the space's `kv` namespace, the run's outbound budget, the
|
|
37
40
|
* space's channels. Everything but `log`, which is the STEP's: each step keeps what it logged.
|
|
38
41
|
*/
|
|
39
|
-
export declare const taskContextFor: (config: ActionsConfig, { kv, email, redactor, signing }: TaskContextDeps, request: TaskContextRequest, signal: AbortSignal, runFetch: typeof fetch) => ((scope: Record<string, unknown>) => Omit<ActionTaskContext, "log">);
|
|
42
|
+
export declare const taskContextFor: (config: ActionsConfig, { kv, email, redactor, signing, later }: TaskContextDeps, request: TaskContextRequest, signal: AbortSignal, runFetch: typeof fetch) => ((scope: Record<string, unknown>) => Omit<ActionTaskContext, "log">);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ActionRunError } from "./errors.js";
|
|
2
2
|
import { namespaceKv } from "./namespaceKv.js";
|
|
3
3
|
import { projectDataReader } from "./projectData.js";
|
|
4
|
+
import { laterFor } from "../jobs/later.js";
|
|
4
5
|
//#region src/modules/actions/runtime/taskContext.ts
|
|
5
6
|
/**
|
|
6
7
|
* Holds an answer to what the run is allowed to carry.
|
|
@@ -66,7 +67,7 @@ var createRunFetch = (base, signal, limits, lineage, onBudgetSpent) => {
|
|
|
66
67
|
* every secret it resolves registered with the redactor, the space's `kv` namespace, the run's outbound budget, the
|
|
67
68
|
* space's channels. Everything but `log`, which is the STEP's: each step keeps what it logged.
|
|
68
69
|
*/
|
|
69
|
-
var taskContextFor = (config, { kv, email, redactor, signing }, request, signal, runFetch) => {
|
|
70
|
+
var taskContextFor = (config, { kv, email, redactor, signing, later }, request, signal, runFetch) => {
|
|
70
71
|
const { realtime } = config;
|
|
71
72
|
const readData = projectDataReader(config.lookups.getData, request.spaceId, request.at);
|
|
72
73
|
return (scope) => ({
|
|
@@ -111,6 +112,11 @@ var taskContextFor = (config, { kv, email, redactor, signing }, request, signal,
|
|
|
111
112
|
spaceId: request.spaceId,
|
|
112
113
|
environment: request.environment
|
|
113
114
|
}),
|
|
115
|
+
...later.queue() ? laterFor(later, {
|
|
116
|
+
spaceId: request.spaceId,
|
|
117
|
+
environment: request.environment,
|
|
118
|
+
...request.at ? { at: request.at } : {}
|
|
119
|
+
}) : {},
|
|
114
120
|
...realtime ? {
|
|
115
121
|
publish: (topic, type, data) => realtime.publish({
|
|
116
122
|
spaceId: request.spaceId,
|
|
@@ -131,65 +131,143 @@ var emit = {
|
|
|
131
131
|
return { emitted: true };
|
|
132
132
|
}
|
|
133
133
|
};
|
|
134
|
+
/**
|
|
135
|
+
* Refuses the run once a caller has asked too often — a contact form, a vote, a comment box, anything public.
|
|
136
|
+
*
|
|
137
|
+
* A fixed window per bucket: at most `limit` runs every `windowSeconds`, counted in the space's `kv` (shared by every
|
|
138
|
+
* replica that shares it). `per: caller` gives each person their own count — the signed-in account, or the address of
|
|
139
|
+
* whoever is not signed in — which is what a public action wants; `per: everyone` is one count for all of them, a
|
|
140
|
+
* ceiling on the action itself. Refused with the message, which the caller sees as the run's `error`.
|
|
141
|
+
*/
|
|
142
|
+
var rateLimit = {
|
|
143
|
+
namespace: "flow",
|
|
144
|
+
action: "rateLimit",
|
|
145
|
+
title: "Rate Limit",
|
|
146
|
+
params: {
|
|
147
|
+
bucket: {
|
|
148
|
+
type: "text",
|
|
149
|
+
canBind: true,
|
|
150
|
+
defaultValue: "",
|
|
151
|
+
label: "Name (what is counted — \"comments\")"
|
|
152
|
+
},
|
|
153
|
+
limit: {
|
|
154
|
+
type: "text",
|
|
155
|
+
canBind: true,
|
|
156
|
+
defaultValue: "10",
|
|
157
|
+
label: "At most"
|
|
158
|
+
},
|
|
159
|
+
windowSeconds: {
|
|
160
|
+
type: "text",
|
|
161
|
+
canBind: true,
|
|
162
|
+
defaultValue: "60",
|
|
163
|
+
label: "Every (seconds)"
|
|
164
|
+
},
|
|
165
|
+
per: {
|
|
166
|
+
type: "select",
|
|
167
|
+
canBind: true,
|
|
168
|
+
defaultValue: "caller",
|
|
169
|
+
label: "Counted",
|
|
170
|
+
options: [{
|
|
171
|
+
label: "For each person",
|
|
172
|
+
value: "caller"
|
|
173
|
+
}, {
|
|
174
|
+
label: "For everyone together",
|
|
175
|
+
value: "everyone"
|
|
176
|
+
}]
|
|
177
|
+
},
|
|
178
|
+
message: {
|
|
179
|
+
type: "text",
|
|
180
|
+
canBind: true,
|
|
181
|
+
defaultValue: "",
|
|
182
|
+
label: "Refusal (what the caller is told)"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
run: async ({ bucket, limit, windowSeconds, per, message }, ctx) => {
|
|
186
|
+
const perSeconds = Number(windowSeconds);
|
|
187
|
+
const { allowed, count, remaining } = await countRate(ctx.kv, ctx.callerId, bucket, {
|
|
188
|
+
most: Number(limit),
|
|
189
|
+
perSeconds,
|
|
190
|
+
per: per === "everyone" ? "everyone" : "caller"
|
|
191
|
+
});
|
|
192
|
+
if (!allowed) throw new ActionRefusal(message || `Too many at once — try again in ${String(perSeconds)} seconds`);
|
|
193
|
+
return {
|
|
194
|
+
count,
|
|
195
|
+
remaining
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
var jsonInput = (value) => {
|
|
200
|
+
if (typeof value !== "string") return value;
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(value || "{}");
|
|
203
|
+
} catch {
|
|
204
|
+
throw new Error("The input of a run set for later is JSON: `{ \"room\": \"{{ input.room }}\" }`");
|
|
205
|
+
}
|
|
206
|
+
};
|
|
134
207
|
var flowTasks = [
|
|
135
208
|
delay,
|
|
136
209
|
fail,
|
|
137
210
|
output,
|
|
138
211
|
onFailure,
|
|
212
|
+
rateLimit,
|
|
139
213
|
{
|
|
140
214
|
namespace: "flow",
|
|
141
|
-
action: "
|
|
142
|
-
title: "
|
|
215
|
+
action: "later",
|
|
216
|
+
title: "Run Later",
|
|
217
|
+
description: "Starts one of the space’s actions — one with a “later” trigger — in so many seconds, on the server.",
|
|
143
218
|
params: {
|
|
144
|
-
|
|
219
|
+
action: {
|
|
145
220
|
type: "text",
|
|
146
221
|
canBind: true,
|
|
147
222
|
defaultValue: "",
|
|
148
|
-
label: "
|
|
223
|
+
label: "Action (its identifier)"
|
|
149
224
|
},
|
|
150
|
-
|
|
225
|
+
in: {
|
|
151
226
|
type: "text",
|
|
152
227
|
canBind: true,
|
|
153
|
-
defaultValue: "
|
|
154
|
-
label: "
|
|
228
|
+
defaultValue: "30",
|
|
229
|
+
label: "In (seconds)"
|
|
155
230
|
},
|
|
156
|
-
|
|
157
|
-
type: "
|
|
231
|
+
input: {
|
|
232
|
+
type: "codemirror-json",
|
|
158
233
|
canBind: true,
|
|
159
|
-
defaultValue: "
|
|
160
|
-
label: "
|
|
234
|
+
defaultValue: "{}",
|
|
235
|
+
label: "Its input"
|
|
161
236
|
},
|
|
162
|
-
|
|
163
|
-
type: "select",
|
|
164
|
-
canBind: true,
|
|
165
|
-
defaultValue: "caller",
|
|
166
|
-
label: "Counted",
|
|
167
|
-
options: [{
|
|
168
|
-
label: "For each person",
|
|
169
|
-
value: "caller"
|
|
170
|
-
}, {
|
|
171
|
-
label: "For everyone together",
|
|
172
|
-
value: "everyone"
|
|
173
|
-
}]
|
|
174
|
-
},
|
|
175
|
-
message: {
|
|
237
|
+
key: {
|
|
176
238
|
type: "text",
|
|
177
239
|
canBind: true,
|
|
178
240
|
defaultValue: "",
|
|
179
|
-
label: "
|
|
241
|
+
label: "Known as (replaces what waits under it)"
|
|
180
242
|
}
|
|
181
243
|
},
|
|
182
|
-
run: async ({
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
244
|
+
run: async ({ action, in: seconds, input, key }, ctx) => {
|
|
245
|
+
if (!ctx.later) throw new Error("This server runs no jobs: nothing can be set to run later");
|
|
246
|
+
const given = jsonInput(input);
|
|
247
|
+
return ctx.later({
|
|
248
|
+
action,
|
|
249
|
+
in: Number(seconds),
|
|
250
|
+
input: typeof given === "object" && given !== null && !Array.isArray(given) ? { ...given } : {},
|
|
251
|
+
...key === "" ? {} : { key }
|
|
188
252
|
});
|
|
189
|
-
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
namespace: "flow",
|
|
257
|
+
action: "cancelLater",
|
|
258
|
+
title: "Cancel Later",
|
|
259
|
+
description: "Drops the runs set for later under a key that are still waiting.",
|
|
260
|
+
params: { key: {
|
|
261
|
+
type: "text",
|
|
262
|
+
canBind: true,
|
|
263
|
+
defaultValue: "",
|
|
264
|
+
label: "Known as"
|
|
265
|
+
} },
|
|
266
|
+
run: async ({ key }, ctx) => {
|
|
267
|
+
if (!ctx.cancelLater) throw new Error("This server runs no jobs: nothing was set to run later");
|
|
190
268
|
return {
|
|
191
|
-
|
|
192
|
-
|
|
269
|
+
key,
|
|
270
|
+
cancelled: await ctx.cancelLater(key)
|
|
193
271
|
};
|
|
194
272
|
}
|
|
195
273
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LaterAnswer, LaterRequest } from './jobs/later';
|
|
1
2
|
import { ActionEmailSender } from './runtime/email';
|
|
2
3
|
import { FunctionsConfig } from '../functions/config';
|
|
3
4
|
import { SpaceFunctions } from '../functions/protocol';
|
|
@@ -196,6 +197,13 @@ export type ActionTaskContext = {
|
|
|
196
197
|
grant?: (topic: string, ttlSeconds?: number) => Promise<string>;
|
|
197
198
|
/** Revokes `grant` for `topic` — or every grant for it, naming none — and lets go whoever is on it with one. */
|
|
198
199
|
revoke?: (topic: string, grant?: string) => Promise<void>;
|
|
200
|
+
/**
|
|
201
|
+
* Starts one of this space's actions in so many seconds, by its `later` trigger (`jobs/later.ts`) — `undefined` when
|
|
202
|
+
* the server runs no jobs. What `flow.later` and a function's `ctx.later` go through.
|
|
203
|
+
*/
|
|
204
|
+
later?: (request: LaterRequest) => Promise<LaterAnswer>;
|
|
205
|
+
/** Drops the runs waiting under `key`, and answers how many. */
|
|
206
|
+
cancelLater?: (key: string) => Promise<number>;
|
|
199
207
|
/** Signs with the space's own key (`ActionsConfig.signingSecret`) — `undefined` when the deployment gave none. */
|
|
200
208
|
sign?: SpaceSigning['sign'];
|
|
201
209
|
verify?: SpaceSigning['verify'];
|
|
@@ -55,7 +55,7 @@ var sourcePlugin = (files) => ({
|
|
|
55
55
|
path: CONTRACT_SPECIFIER,
|
|
56
56
|
namespace: "contract"
|
|
57
57
|
};
|
|
58
|
-
if (args.kind !== "entry-point" && !args.path.startsWith(".")) return { errors: [{ text: `"${args.path}" cannot be imported: functions import their own files and ${CONTRACT_SPECIFIER}, nothing else
|
|
58
|
+
if (args.kind !== "entry-point" && !args.path.startsWith(".")) return { errors: [{ text: `"${args.path}" cannot be imported: functions import their own files and ${CONTRACT_SPECIFIER}, nothing else — a package of web APIs is bundled in as one of their files with \`plitzi functions add ${args.path.startsWith("node:") ? "<package>" : args.path}\`` }] };
|
|
59
59
|
const base = args.kind === "entry-point" ? "." : path.posix.dirname(args.importer);
|
|
60
60
|
const wanted = normalize(path.posix.join(base, args.path));
|
|
61
61
|
if (wanted === ".." || wanted.startsWith("../")) return { errors: [{ text: outsideFunctions(args.path, wanted) }] };
|
|
@@ -129,6 +129,17 @@ var fetchInitOf = (value) => {
|
|
|
129
129
|
};
|
|
130
130
|
};
|
|
131
131
|
var optionalString = (value, what) => value === void 0 || value === null ? void 0 : stringArg(value, what);
|
|
132
|
+
/** What a run set for later is checked against where it is queued (`laterFor`); here only that it is what it claims. */
|
|
133
|
+
var laterOf = (value) => {
|
|
134
|
+
const request = recordArg(value, "A run set for later");
|
|
135
|
+
const key = optionalString(request.key, "A key");
|
|
136
|
+
return {
|
|
137
|
+
action: stringArg(request.action, "An action"),
|
|
138
|
+
in: numberArg(request.in, "in"),
|
|
139
|
+
...request.input === void 0 ? {} : { input: recordArg(request.input, "An input") },
|
|
140
|
+
...key === void 0 ? {} : { key }
|
|
141
|
+
};
|
|
142
|
+
};
|
|
132
143
|
/** A limit's numbers are checked where it is counted (`countRate`); here only that they are what they claim. */
|
|
133
144
|
var rateLimitOf = (value) => {
|
|
134
145
|
const limit = recordArg(value, "A rate limit");
|
|
@@ -177,6 +188,14 @@ var readCall = (value) => {
|
|
|
177
188
|
...grant === void 0 ? {} : { grant }
|
|
178
189
|
};
|
|
179
190
|
}
|
|
191
|
+
case "later": return {
|
|
192
|
+
op: "later",
|
|
193
|
+
request: laterOf(call.request)
|
|
194
|
+
};
|
|
195
|
+
case "cancelLater": return {
|
|
196
|
+
op: "cancelLater",
|
|
197
|
+
key: stringArg(call.key, "A key")
|
|
198
|
+
};
|
|
180
199
|
case "rateLimit": return {
|
|
181
200
|
op: "rateLimit",
|
|
182
201
|
bucket: stringArg(call.bucket, "A bucket"),
|
|
@@ -217,6 +236,8 @@ var answerCall = async (ctx, call) => {
|
|
|
217
236
|
case "publish": return ctx.publish(call.topic, call.type, call.data);
|
|
218
237
|
case "grant": return ctx.grant(call.topic, call.ttlSeconds);
|
|
219
238
|
case "revoke": return ctx.revoke(call.topic, call.grant);
|
|
239
|
+
case "later": return ctx.later(call.request);
|
|
240
|
+
case "cancelLater": return ctx.cancelLater(call.key);
|
|
220
241
|
case "rateLimit": return ctx.rateLimit(call.bucket, call.limit);
|
|
221
242
|
case "sign": return ctx.sign(call.value);
|
|
222
243
|
case "verify": return ctx.verify(call.value, call.signature);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { prefixKv } from "../actions/runtime/namespaceKv.js";
|
|
2
2
|
import { fetchOutbound } from "../../helpers/outboundGuard.js";
|
|
3
|
-
import { countRate } from "../actions/runtime/rateLimit.js";
|
|
3
|
+
import { countRate, refusedOver } from "../actions/runtime/rateLimit.js";
|
|
4
4
|
import { pluginKvPrefix, pluginSigned } from "./scope.js";
|
|
5
5
|
import { processTwigParam } from "@plitzi/sdk-shared/helpers/twigWrapper";
|
|
6
6
|
//#region src/modules/functions/context.ts
|
|
@@ -64,6 +64,7 @@ var lineOf = (values) => values.map((value) => {
|
|
|
64
64
|
}).join(" ");
|
|
65
65
|
var unavailable = (what) => () => Promise.reject(/* @__PURE__ */ new Error(`This server has no realtime channels (${what})`));
|
|
66
66
|
var unsigned = () => Promise.reject(/* @__PURE__ */ new Error("This server signs nothing: its actions were given no signingSecret"));
|
|
67
|
+
var noJobs = () => Promise.reject(/* @__PURE__ */ new Error("This server runs no jobs: nothing can be set to run later"));
|
|
67
68
|
/**
|
|
68
69
|
* A plugin reads none of its space's data. The space keeps there what its pages must not carry, and a plugin was trusted
|
|
69
70
|
* to draw, not with what the space keeps — the same line as its credentials: handed the data, its `fetch` could send
|
|
@@ -91,26 +92,31 @@ var functionContextFor = (ctx, hosts, scope) => {
|
|
|
91
92
|
if (!scope) return {
|
|
92
93
|
...shared,
|
|
93
94
|
kv: ctx.kv,
|
|
94
|
-
rateLimit: (bucket, limit) => countRate(ctx.kv, ctx.callerId, bucket, limit),
|
|
95
|
+
rateLimit: async (bucket, limit) => refusedOver(await countRate(ctx.kv, ctx.callerId, bucket, limit), limit),
|
|
95
96
|
sign: ctx.sign ?? unsigned,
|
|
96
97
|
verify: ctx.verify ?? unsigned,
|
|
97
98
|
publish: ctx.publish ?? unavailable("publish"),
|
|
98
99
|
grant: ctx.grant ?? unavailable("grant"),
|
|
99
100
|
revoke: ctx.revoke ?? unavailable("revoke"),
|
|
101
|
+
later: ctx.later ?? noJobs,
|
|
102
|
+
cancelLater: ctx.cancelLater ?? noJobs,
|
|
100
103
|
data: ctx.data
|
|
101
104
|
};
|
|
102
105
|
const kv = prefixKv(ctx.kv, pluginKvPrefix(scope.plugin));
|
|
103
106
|
const { sign, verify } = ctx;
|
|
104
107
|
const noChannel = () => Promise.reject(/* @__PURE__ */ new Error("The functions of a plugin reach none of the realtime channels of its space"));
|
|
108
|
+
const noLater = () => Promise.reject(/* @__PURE__ */ new Error("The functions of a plugin set none of the actions of its space to run"));
|
|
105
109
|
return {
|
|
106
110
|
...shared,
|
|
107
111
|
kv,
|
|
108
|
-
rateLimit: (bucket, limit) => countRate(kv, ctx.callerId, bucket, limit),
|
|
112
|
+
rateLimit: async (bucket, limit) => refusedOver(await countRate(kv, ctx.callerId, bucket, limit), limit),
|
|
109
113
|
sign: sign ? (value) => sign(pluginSigned(scope.plugin, value)) : unsigned,
|
|
110
114
|
verify: verify ? (value, signature) => verify(pluginSigned(scope.plugin, value), signature) : unsigned,
|
|
111
115
|
publish: noChannel,
|
|
112
116
|
grant: noChannel,
|
|
113
117
|
revoke: noChannel,
|
|
118
|
+
later: noLater,
|
|
119
|
+
cancelLater: noLater,
|
|
114
120
|
data: noData
|
|
115
121
|
};
|
|
116
122
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LaterAnswer, LaterRequest } from '../actions/jobs/later';
|
|
1
2
|
import { RateCount, RateLimit } from '../actions/runtime/rateLimit';
|
|
2
3
|
import { ActionKvStore } from '../actions/types';
|
|
3
4
|
import { Environment, FunctionTimeLimits, InteractionCallbackParam } from '@plitzi/sdk-shared';
|
|
@@ -67,6 +68,15 @@ export type FunctionContext = {
|
|
|
67
68
|
grant: (topic: string, ttlSeconds?: number) => Promise<string>;
|
|
68
69
|
/** Takes a grant back — or every grant for the topic, naming none. */
|
|
69
70
|
revoke: (topic: string, grant?: string) => Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Starts one of the space's actions — one with a `later` trigger — in so many seconds, on the server, whether or not a
|
|
73
|
+
* page is still open: a turn that runs out, a bot's move, a hold that lapses. A `key` names it: set again under the
|
|
74
|
+
* same key, the one still waiting is replaced. Answers the job and when it is due (epoch ms, by the queue's clock).
|
|
75
|
+
* `ctx.later({ action: 'game.timeout', in: 45, input: { room }, key: 'turn:' + room })`.
|
|
76
|
+
*/
|
|
77
|
+
later: (request: LaterRequest) => Promise<LaterAnswer>;
|
|
78
|
+
/** Drops what waits under `key` — the timer of a game that ended — and answers how many. */
|
|
79
|
+
cancelLater: (key: string) => Promise<number>;
|
|
70
80
|
/**
|
|
71
81
|
* One file of the space's data — a project's `src/data/<file>` — parsed, as of the version the run belongs to: what
|
|
72
82
|
* a page must not carry, read here and answered only as far as it is shown. Read-only. `ctx.data('products.json')`.
|
|
@@ -99,11 +99,23 @@ var createFunctionsDriver = (environment) => {
|
|
|
99
99
|
grant
|
|
100
100
|
});
|
|
101
101
|
},
|
|
102
|
-
|
|
103
|
-
op: "
|
|
104
|
-
|
|
105
|
-
limit
|
|
102
|
+
later: (request) => call({
|
|
103
|
+
op: "later",
|
|
104
|
+
request
|
|
106
105
|
}),
|
|
106
|
+
cancelLater: (key) => call({
|
|
107
|
+
op: "cancelLater",
|
|
108
|
+
key
|
|
109
|
+
}),
|
|
110
|
+
rateLimit: async (bucket, limit) => {
|
|
111
|
+
const count = await call({
|
|
112
|
+
op: "rateLimit",
|
|
113
|
+
bucket,
|
|
114
|
+
limit
|
|
115
|
+
});
|
|
116
|
+
if (isRecord(limit) && typeof limit.refuse === "string" && isRecord(count) && count.allowed === false) throw refusal(limit.refuse);
|
|
117
|
+
return count;
|
|
118
|
+
},
|
|
107
119
|
sign: (value) => call({
|
|
108
120
|
op: "sign",
|
|
109
121
|
value
|
|
@@ -3,7 +3,7 @@ import { FunctionLimits } from './protocol';
|
|
|
3
3
|
import { ActionRunResult } from '../actions';
|
|
4
4
|
import { FunctionsProblem } from '@plitzi/sdk-shared';
|
|
5
5
|
export type LocalFunctionsOptions = {
|
|
6
|
-
/** Credentials `ctx.fetch` may name, by id —
|
|
6
|
+
/** Credentials `ctx.fetch` may name, by id — by default the project's own, from its environment (`PLITZI_CREDENTIALS`). */
|
|
7
7
|
credentials?: Record<string, Record<string, string>>;
|
|
8
8
|
/** As the platform's: a plan's limits, for code that must fit them there. */
|
|
9
9
|
limits?: Partial<FunctionLimits>;
|
|
@@ -3,6 +3,7 @@ import { functionsInHand } from "./space.js";
|
|
|
3
3
|
import { createActionsModule } from "../actions/index.js";
|
|
4
4
|
import { functionTryEntry } from "./tryEntry.js";
|
|
5
5
|
import { createIsolateRunner } from "./sandbox/isolate.js";
|
|
6
|
+
import { credentialsFromEnv } from "../actions/runtime/credentials.js";
|
|
6
7
|
import { randomUUID } from "node:crypto";
|
|
7
8
|
//#region src/modules/functions/local.ts
|
|
8
9
|
/** Who a local run is for: somebody signed in, with nothing to forward. */
|
|
@@ -20,7 +21,7 @@ var LOCAL_USER = {
|
|
|
20
21
|
* isolates and limits, `ctx` over the same actions module — so what works here works there. `kv` lives as long as this
|
|
21
22
|
* does. What `plitzi functions dev` is made of; needs `isolated-vm` and `core-js` installed, as any runner does.
|
|
22
23
|
*/
|
|
23
|
-
var createLocalFunctions = ({ credentials =
|
|
24
|
+
var createLocalFunctions = ({ credentials = credentialsFromEnv(), limits, dataDir, fetchImpl } = {}) => {
|
|
24
25
|
const runner = createIsolateRunner({ concurrency: 1 });
|
|
25
26
|
let loaded;
|
|
26
27
|
const module = createActionsModule({
|
|
@@ -47,20 +47,25 @@ var paramOf = (value) => {
|
|
|
47
47
|
const base = {
|
|
48
48
|
...typeof value.canBind === "boolean" ? { canBind: value.canBind } : {},
|
|
49
49
|
...typeof value.label === "string" ? { label: value.label } : {},
|
|
50
|
-
...typeof value.when === "boolean" ? { when: value.when } : {}
|
|
50
|
+
...typeof value.when === "boolean" ? { when: value.when } : {},
|
|
51
|
+
...typeof value.required === "boolean" ? { required: value.required } : {}
|
|
51
52
|
};
|
|
53
|
+
const bound = (key) => typeof value[key] === "number" && Number.isFinite(value[key]) ? { [key]: value[key] } : {};
|
|
52
54
|
const defaultValue = value.defaultValue;
|
|
53
55
|
switch (value.type) {
|
|
54
56
|
case "text":
|
|
55
57
|
case "textarea": return {
|
|
56
58
|
...base,
|
|
57
59
|
type: value.type,
|
|
58
|
-
...typeof defaultValue === "string" || typeof defaultValue === "number" ? { defaultValue } : {}
|
|
60
|
+
...typeof defaultValue === "string" || typeof defaultValue === "number" ? { defaultValue } : {},
|
|
61
|
+
...bound("maxLength")
|
|
59
62
|
};
|
|
60
63
|
case "number": return {
|
|
61
64
|
...base,
|
|
62
65
|
type: "number",
|
|
63
|
-
...typeof defaultValue === "number" ? { defaultValue } : {}
|
|
66
|
+
...typeof defaultValue === "number" ? { defaultValue } : {},
|
|
67
|
+
...bound("min"),
|
|
68
|
+
...bound("max")
|
|
64
69
|
};
|
|
65
70
|
case "codemirror-text":
|
|
66
71
|
case "codemirror-json": return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FunctionContext, FunctionFetchInit } from './contract';
|
|
2
|
+
import { LaterRequest } from '../actions/jobs/later';
|
|
2
3
|
import { RateLimit } from '../actions/runtime/rateLimit';
|
|
3
4
|
import { FunctionsManifest } from '@plitzi/sdk-shared';
|
|
4
5
|
/**
|
|
@@ -93,6 +94,12 @@ export type FunctionCall = {
|
|
|
93
94
|
op: 'revoke';
|
|
94
95
|
topic: string;
|
|
95
96
|
grant?: string;
|
|
97
|
+
} | {
|
|
98
|
+
op: 'later';
|
|
99
|
+
request: LaterRequest;
|
|
100
|
+
} | {
|
|
101
|
+
op: 'cancelLater';
|
|
102
|
+
key: string;
|
|
96
103
|
} | {
|
|
97
104
|
op: 'rateLimit';
|
|
98
105
|
bucket: string;
|
|
@@ -86,6 +86,11 @@ var createMongoJobQueue = ({ db, collections: names = {}, retentionDays = 90 })
|
|
|
86
86
|
actionId: 1,
|
|
87
87
|
updatedAt: -1
|
|
88
88
|
}),
|
|
89
|
+
jobsOf().createIndex({
|
|
90
|
+
spaceId: 1,
|
|
91
|
+
key: 1,
|
|
92
|
+
status: 1
|
|
93
|
+
}, { partialFilterExpression: { key: { $exists: true } } }),
|
|
89
94
|
jobsOf().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
|
|
90
95
|
schedulesOf().createIndex({
|
|
91
96
|
enabled: 1,
|
|
@@ -328,6 +333,27 @@ var createMongoJobQueue = ({ db, collections: names = {}, retentionDays = 90 })
|
|
|
328
333
|
cancelRequested: true,
|
|
329
334
|
updatedAt: at
|
|
330
335
|
} })).matchedCount === 1;
|
|
336
|
+
},
|
|
337
|
+
cancelPending: async ({ spaceId, key, olderThan }) => {
|
|
338
|
+
const { jobs } = await collections();
|
|
339
|
+
const newest = olderThan === void 0 ? void 0 : await jobs.findOne({ _id: olderThan });
|
|
340
|
+
const at = await clock.now();
|
|
341
|
+
return (await jobs.updateMany({
|
|
342
|
+
spaceId,
|
|
343
|
+
key,
|
|
344
|
+
status: "pending",
|
|
345
|
+
...newest ? { $or: [{ createdAt: { $lt: newest.createdAt } }, {
|
|
346
|
+
createdAt: newest.createdAt,
|
|
347
|
+
_id: { $lt: newest._id }
|
|
348
|
+
}] } : {}
|
|
349
|
+
}, {
|
|
350
|
+
$set: {
|
|
351
|
+
status: "cancelled",
|
|
352
|
+
updatedAt: at,
|
|
353
|
+
expiresAt: expiryOf(at)
|
|
354
|
+
},
|
|
355
|
+
$unset: { workerId: "" }
|
|
356
|
+
})).modifiedCount;
|
|
331
357
|
}
|
|
332
358
|
};
|
|
333
359
|
};
|