@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +287 -0
  2. package/README.md +3 -3
  3. package/dist/auth.js +1 -1
  4. package/dist/core/auth/createAuth.js +1 -1
  5. package/dist/core/http/stages/signInRoute.js +1 -1
  6. package/dist/core/server/baseServer.js +11 -6
  7. package/dist/core/server/fleet/stores.d.ts +7 -0
  8. package/dist/core/server/fleet/stores.js +16 -2
  9. package/dist/core/server/fleet/supervisor.d.ts +2 -0
  10. package/dist/core/server/fleet/supervisor.js +14 -2
  11. package/dist/core/server/pageServer.js +1 -1
  12. package/dist/functions-api.d.ts +47 -2
  13. package/dist/helpers/cache/cacheManager.d.ts +9 -2
  14. package/dist/helpers/cache/cacheManager.js +46 -22
  15. package/dist/helpers/cache/keys.d.ts +12 -5
  16. package/dist/helpers/cache/keys.js +56 -11
  17. package/dist/modules/actions/index.js +6 -2
  18. package/dist/modules/actions/jobs/later.d.ts +43 -0
  19. package/dist/modules/actions/jobs/later.js +66 -0
  20. package/dist/modules/actions/jobs/later.test.d.ts +1 -0
  21. package/dist/modules/actions/jobs/memoryQueue.js +14 -1
  22. package/dist/modules/actions/jobs/worker.js +2 -2
  23. package/dist/modules/actions/runtime/credentials.d.ts +10 -0
  24. package/dist/modules/actions/runtime/credentials.js +28 -0
  25. package/dist/modules/actions/runtime/credentials.test.d.ts +1 -0
  26. package/dist/modules/actions/runtime/fileKv.d.ts +8 -3
  27. package/dist/modules/actions/runtime/fileKv.js +79 -7
  28. package/dist/modules/actions/runtime/precheck.js +1 -1
  29. package/dist/modules/actions/runtime/rateLimit.d.ts +7 -0
  30. package/dist/modules/actions/runtime/rateLimit.js +7 -1
  31. package/dist/modules/actions/runtime/runAction.d.ts +4 -1
  32. package/dist/modules/actions/runtime/runAction.js +46 -11
  33. package/dist/modules/actions/runtime/taskContext.d.ts +4 -1
  34. package/dist/modules/actions/runtime/taskContext.js +7 -1
  35. package/dist/modules/actions/tasks/flow.js +113 -35
  36. package/dist/modules/actions/types.d.ts +8 -0
  37. package/dist/modules/functions/build.js +1 -1
  38. package/dist/modules/functions/capabilities.js +21 -0
  39. package/dist/modules/functions/context.js +9 -3
  40. package/dist/modules/functions/contract.d.ts +10 -0
  41. package/dist/modules/functions/driver.js +16 -4
  42. package/dist/modules/functions/local.d.ts +1 -1
  43. package/dist/modules/functions/local.js +2 -1
  44. package/dist/modules/functions/manifest.js +8 -3
  45. package/dist/modules/functions/protocol.d.ts +7 -0
  46. package/dist/modules/mongo/jobQueue.js +26 -0
  47. package/dist/modules/mysql/jobs/jobQueue.js +21 -2
  48. package/dist/modules/mysql/jobs/schema.d.ts +3 -2
  49. package/dist/modules/mysql/jobs/schema.js +24 -11
  50. package/dist/modules/project/plugins.d.ts +1 -1
  51. package/dist/modules/project/serveProject.js +29 -4
  52. package/dist/modules/realtime/handlers.d.ts +4 -1
  53. package/dist/modules/realtime/handlers.js +8 -4
  54. package/dist/modules/realtime/hub.d.ts +3 -1
  55. package/dist/modules/realtime/hub.js +8 -4
  56. package/dist/modules/ssr/prepareRender.js +1 -1
  57. package/package.json +3 -3
  58. package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
  59. package/skills/plitzi-authoring/recipes/plugin.ts +9 -7
  60. package/skills/plitzi-authoring/recipes/style-a-page.ts +1 -1
  61. package/skills/plitzi-authoring/reference/accessibility.md +1 -0
  62. package/skills/plitzi-authoring/reference/authoring-errors.md +8 -4
  63. package/skills/plitzi-authoring/reference/data-and-visibility.md +11 -10
  64. package/skills/plitzi-authoring/reference/elements-and-styles.md +1 -1
  65. package/skills/plitzi-authoring/reference/flows.md +6 -5
  66. package/skills/plitzi-authoring/reference/layouts.md +1 -0
  67. package/skills/plitzi-authoring/reference/lists.md +1 -0
  68. package/skills/plitzi-authoring/reference/plugins.md +43 -37
  69. package/skills/plitzi-authoring/reference/realtime.md +4 -1
  70. package/skills/plitzi-authoring/reference/rules.md +1 -1
  71. package/skills/plitzi-authoring/reference/typed-sources.md +6 -4
@@ -28,6 +28,7 @@ var toJob = (row) => ({
28
28
  dueAt: Number(row.due_at),
29
29
  maxAttempts: Number(row.max_attempts),
30
30
  ...row.missed === null ? {} : { missed: Number(row.missed) },
31
+ ...row.job_key === null ? {} : { key: row.job_key },
31
32
  status: row.status,
32
33
  runAt: Number(row.run_at),
33
34
  attempts: Number(row.attempts),
@@ -128,8 +129,8 @@ var createMysqlJobQueue = ({ pool, tablePrefix = "", createTables = true, retent
128
129
  const at = await clock.now();
129
130
  try {
130
131
  await execute(conn, `INSERT INTO ${t.jobs} (id, space_id, action_id, environment, trigger_type, input, due_at, max_attempts,
131
- missed, status, run_at, attempts, created_at, updated_at, history)
132
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, ?, ?, '[]')`, [
132
+ missed, job_key, status, run_at, attempts, created_at, updated_at, history)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, ?, ?, '[]')`, [
133
134
  job.id,
134
135
  job.spaceId,
135
136
  job.actionId,
@@ -139,6 +140,7 @@ var createMysqlJobQueue = ({ pool, tablePrefix = "", createTables = true, retent
139
140
  job.dueAt,
140
141
  job.maxAttempts,
141
142
  job.missed ?? null,
143
+ job.key ?? null,
142
144
  job.dueAt,
143
145
  at,
144
146
  at
@@ -368,6 +370,23 @@ var createMysqlJobQueue = ({ pool, tablePrefix = "", createTables = true, retent
368
370
  jobId,
369
371
  spaceIds
370
372
  ])).affectedRows === 1;
373
+ },
374
+ cancelPending: async ({ spaceId, key, olderThan }) => {
375
+ const conn = await db();
376
+ const newest = olderThan === void 0 ? void 0 : await selectOne(conn, `SELECT id, created_at FROM ${t.jobs} WHERE id = ?`, [olderThan]);
377
+ const at = await clock.now();
378
+ return (await execute(conn, `UPDATE ${t.jobs} SET status = 'cancelled', updated_at = ?, expires_at = ?, worker_id = NULL
379
+ WHERE space_id = ? AND job_key = ? AND status = 'pending'${newest ? " AND (created_at < ? OR (created_at = ? AND id < ?))" : ""}`, [
380
+ at,
381
+ expiryOf(at),
382
+ spaceId,
383
+ key,
384
+ ...newest ? [
385
+ newest.created_at,
386
+ newest.created_at,
387
+ newest.id
388
+ ] : []
389
+ ])).affectedRows;
371
390
  }
372
391
  };
373
392
  };
@@ -23,8 +23,9 @@ export declare const jobTables: (prefix?: string) => JobTables;
23
23
  */
24
24
  export declare const mysqlJobSchemaStatements: (prefix?: string) => string[];
25
25
  /**
26
- * What brings a `kv` table made before the columns above up to them — for a deployment that runs its own migrations,
27
- * once. `ensureJobTables` does it by itself, and only when the table needs it: the `ALTER` copies the table.
26
+ * What brings tables made before the columns above up to them — for a deployment that runs its own migrations: each
27
+ * statement once, the ones its tables still lack. `ensureJobTables` does it by itself, and only where a table needs
28
+ * it: an `ALTER` copies the table.
28
29
  */
29
30
  export declare const mysqlJobSchemaUpgrades: (prefix?: string) => string[];
30
31
  /** The server's now, in epoch ms, as an SQL expression — what every comparison reads instead of a caller's clock. */
@@ -38,6 +38,7 @@ var mysqlJobSchemaStatements = (prefix = "") => {
38
38
  due_at BIGINT NOT NULL,
39
39
  max_attempts INT NOT NULL,
40
40
  missed INT NULL,
41
+ job_key VARCHAR(191) NULL,
41
42
  status VARCHAR(16) NOT NULL,
42
43
  run_at BIGINT NOT NULL,
43
44
  attempts INT NOT NULL DEFAULT 0,
@@ -54,6 +55,7 @@ var mysqlJobSchemaStatements = (prefix = "") => {
54
55
  KEY action_jobs_pending (status, run_at),
55
56
  KEY action_jobs_lease (status, lease_until),
56
57
  KEY action_jobs_space (space_id, updated_at),
58
+ KEY action_jobs_key (space_id, job_key, status),
57
59
  KEY action_jobs_expires (expires_at)
58
60
  ) ${CHARSET}`,
59
61
  `CREATE TABLE IF NOT EXISTS ${t.schedules} (
@@ -80,18 +82,29 @@ var mysqlJobSchemaStatements = (prefix = "") => {
80
82
  ) ${CHARSET}`
81
83
  ];
82
84
  };
83
- /**
84
- * What brings a `kv` table made before the columns above up to them — for a deployment that runs its own migrations,
85
- * once. `ensureJobTables` does it by itself, and only when the table needs it: the `ALTER` copies the table.
86
- */
87
- var mysqlJobSchemaUpgrades = (prefix = "") => [`ALTER TABLE ${jobTables(prefix).kv} MODIFY k VARBINARY(764) NOT NULL, MODIFY v MEDIUMTEXT NOT NULL`];
88
- /** Whether the `kv` table still has the columns it was first made with. */
89
- var kvNeedsUpgrade = async (db, prefix) => {
85
+ /** The columns a table of this database has, by name, with their types. */
86
+ var columnsOf = async (db, table) => {
90
87
  const columns = await selectRows(db, `SELECT COLUMN_NAME AS name, DATA_TYPE AS type FROM INFORMATION_SCHEMA.COLUMNS
91
- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME IN ('k', 'v')`, [`${prefix}action_kv`]);
92
- const typeOf = (name) => columns.find((column) => column.name === name)?.type.toLowerCase();
93
- return typeOf("k") !== "varbinary" || typeOf("v") !== "mediumtext";
88
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`, [table]);
89
+ return new Map(columns.map((column) => [column.name, column.type.toLowerCase()]));
94
90
  };
91
+ var UPGRADES = [{
92
+ needed: async (db, prefix) => {
93
+ const columns = await columnsOf(db, `${prefix}action_kv`);
94
+ return columns.get("k") !== "varbinary" || columns.get("v") !== "mediumtext";
95
+ },
96
+ statements: (prefix) => [`ALTER TABLE ${jobTables(prefix).kv} MODIFY k VARBINARY(764) NOT NULL, MODIFY v MEDIUMTEXT NOT NULL`]
97
+ }, {
98
+ needed: async (db, prefix) => !(await columnsOf(db, `${prefix}action_jobs`)).has("job_key"),
99
+ statements: (prefix) => [`ALTER TABLE ${jobTables(prefix).jobs} ADD COLUMN job_key VARCHAR(191) NULL AFTER missed,
100
+ ADD KEY action_jobs_key (space_id, job_key, status)`]
101
+ }];
102
+ /**
103
+ * What brings tables made before the columns above up to them — for a deployment that runs its own migrations: each
104
+ * statement once, the ones its tables still lack. `ensureJobTables` does it by itself, and only where a table needs
105
+ * it: an `ALTER` copies the table.
106
+ */
107
+ var mysqlJobSchemaUpgrades = (prefix = "") => UPGRADES.flatMap((upgrade) => upgrade.statements(prefix));
95
108
  /** The server's now, in epoch ms, as an SQL expression — what every comparison reads instead of a caller's clock. */
96
109
  var NOW_MS = "CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED)";
97
110
  /** Creates the tables once per process, on first use; a failure is retried by the next call rather than remembered. */
@@ -101,7 +114,7 @@ var ensureJobTables = (db, prefix, create) => {
101
114
  if (!create) return;
102
115
  ready ??= (async () => {
103
116
  for (const statement of mysqlJobSchemaStatements(prefix)) await execute(db, statement);
104
- if (await kvNeedsUpgrade(db, prefix)) for (const statement of mysqlJobSchemaUpgrades(prefix)) await execute(db, statement);
117
+ for (const upgrade of UPGRADES) if (await upgrade.needed(db, prefix)) for (const statement of upgrade.statements(prefix)) await execute(db, statement);
105
118
  })().catch((error) => {
106
119
  ready = void 0;
107
120
  throw error;
@@ -5,7 +5,7 @@ import { PluginSource, SSRServer } from '@plitzi/sdk-shared';
5
5
  * plugin of `vendor/plugins` run as it was built — each with its server half, when it has one.
6
6
  */
7
7
  export type ProjectPlugins = {
8
- /** How each is built, by the name a space's `custom({ renderType })` renders it by. */
8
+ /** How each is built, by the type a space's element of it is — `defineElement(declaration)` — rendered by. */
9
9
  sources: Record<string, PluginSource>;
10
10
  /**
11
11
  * Which of them the space renders with — the deployment's `pluginNames`. Registering a plugin is not turning it on:
@@ -7,11 +7,14 @@ import { freePort } from "../../core/freePort.js";
7
7
  import { createCloudAdapters } from "../../adapters/cloudAdapters.js";
8
8
  import { createJsonAdapters } from "../../adapters/jsonAdapters.js";
9
9
  import { createFileKv } from "../actions/runtime/fileKv.js";
10
+ import { credentialsFromEnv } from "../actions/runtime/credentials.js";
10
11
  import { loadRuntime, loadRuntimeModule } from "../runtime/bundle.js";
11
12
  import { serveRuntime } from "../runtime/stages.js";
12
13
  import { projectPlugins, watchProjectPlugins } from "./plugins.js";
13
14
  import { createSpaceFailure } from "./spaceFailure.js";
14
15
  import { watchSpace } from "./watchSpace.js";
16
+ import { fleetKv } from "../../core/server/fleet/stores.js";
17
+ import { networkInterfaces } from "node:os";
15
18
  import path from "node:path";
16
19
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
20
  import { assertProjectLayout, layoutFindingText } from "@plitzi/sdk-shared/project/layout";
@@ -60,8 +63,19 @@ var cloudAdapters = (pluginNames) => {
60
63
  deployment: { pluginNames }
61
64
  });
62
65
  };
63
- var actionLookups = (actions, connectors) => ({
66
+ var LOOPBACK = /* @__PURE__ */ new Set([
67
+ "127.0.0.1",
68
+ "::1",
69
+ "localhost"
70
+ ]);
71
+ /** Where another device reaches a server listening on `host`: this machine's own network addresses, when it is open. */
72
+ var networkUrls = (host, port) => {
73
+ if (LOOPBACK.has(host)) return [];
74
+ return (host === "0.0.0.0" || host === "::" ? Object.values(networkInterfaces()).flat().flatMap((entry) => entry && entry.family === "IPv4" && !entry.internal ? [entry.address] : []) : [host]).map((address) => `http://${address}:${String(port)}/`);
75
+ };
76
+ var actionLookups = (actions, connectors, credentials) => ({
64
77
  getAction: (_spaceId, actionId) => Promise.resolve(actions.find((entry) => entry.id === actionId)),
78
+ getCredential: (_spaceId, identifier) => Promise.resolve(Object.hasOwn(credentials, identifier) ? credentials[identifier] : void 0),
65
79
  listActions: () => Promise.resolve([...actions]),
66
80
  getConnector: (_spaceId, connectorId) => Promise.resolve(connectors.get(connectorId)),
67
81
  listScheduledSpaces: () => Promise.resolve([SPACE_ID])
@@ -137,6 +151,14 @@ var startProject = async (options) => {
137
151
  env: process.env,
138
152
  publicUrl
139
153
  });
154
+ let keptKv;
155
+ const kv = fleetKv(() => {
156
+ keptKv = createFileKv({
157
+ file: path.join(root, KV_FILE),
158
+ owner: `the server of "${name}", on port ${String(port)}`
159
+ });
160
+ return keptKv;
161
+ });
140
162
  const server = createServer({
141
163
  logLevel: process.argv.includes("--verbose") ? "info" : "warn",
142
164
  logger: consoleLogger,
@@ -155,12 +177,12 @@ var startProject = async (options) => {
155
177
  },
156
178
  action: {
157
179
  signingSecret: process.env.PLITZI_SIGNING_SECRET,
158
- kv: createFileKv({ file: path.join(root, KV_FILE) }),
180
+ kv,
159
181
  ...serverOptions.action,
160
- ...actions ? { lookups: actionLookups(actions, connectors) } : {}
182
+ ...actions ? { lookups: actionLookups(actions, connectors, credentialsFromEnv()) } : {}
161
183
  }
162
184
  }, { preAuth: [spaceFailure.stage, ...runtime ? [runtime.stage] : []] });
163
- server.listen(port, host);
185
+ await server.listen(port, host);
164
186
  const url = `http://127.0.0.1:${String(port)}`;
165
187
  if (speaks) {
166
188
  mkdirSync(path.join(root, PROJECT_TMP), { recursive: true });
@@ -170,6 +192,8 @@ var startProject = async (options) => {
170
192
  url
171
193
  }, null, 2)}\n`);
172
194
  console.log(`pages on ${url}/`);
195
+ const network = networkUrls(host, port);
196
+ if (network.length > 0) console.log(`also on ${network.join(", ")} — anyone on this network can open it (HOST=${host})`);
173
197
  }
174
198
  const watching = developing ? [...held ? [watchSpace(root, (authored) => {
175
199
  held.documents = authored;
@@ -180,6 +204,7 @@ var startProject = async (options) => {
180
204
  watching.forEach((stop) => stop());
181
205
  await server.close();
182
206
  await runtime?.close();
207
+ await keptKv?.close();
183
208
  };
184
209
  const stopListening = closeOnSignals({ close });
185
210
  return {
@@ -28,6 +28,8 @@ export type Admission = {
28
28
  /** The grant each accepted topic of a `grant: true` channel was opened with. */
29
29
  granted: Map<string, string>;
30
30
  refused: RealtimeRefusal[];
31
+ /** It hears and never speaks: the page asked to watch (`OBSERVER_COOKIE_NAME`). */
32
+ observer: boolean;
31
33
  };
32
34
  /**
33
35
  * Which of the topics a page asked for (`?topics=a,b`) it may open: each checked against the channels the space
@@ -44,10 +46,11 @@ export declare const connectionFor: (admission: Extract<Admission, {
44
46
  * may send on, within its size and its rate — and stamped by the server with who sent it and when, whatever the
45
47
  * message said.
46
48
  */
47
- export declare const publishFrom: (hub: RealtimeHub, connection: RealtimeConnection, { topic, type, data }: {
49
+ export declare const publishFrom: (hub: RealtimeHub, connection: RealtimeConnection, { topic, type, data, echo }: {
48
50
  topic?: unknown;
49
51
  type?: unknown;
50
52
  data?: unknown;
53
+ echo?: unknown;
51
54
  }) => Promise<RealtimeAnswer>;
52
55
  export type SubscribeDeps = {
53
56
  req: SSRRequest;
@@ -1,9 +1,10 @@
1
1
  import onAbort from "../../helpers/onAbort.js";
2
2
  import { openEventStream } from "../../core/http/sse.js";
3
3
  import { warnRealtime } from "./failure.js";
4
+ import { readCookies } from "../../core/auth/credentials.js";
4
5
  import { accessRefusal } from "@plitzi/sdk-shared/actions";
5
6
  import { randomBytes, randomUUID } from "node:crypto";
6
- import { PRESENCE_TYPE, channelLimits, matchChannel } from "@plitzi/sdk-shared/realtime";
7
+ import { OBSERVER_COOKIE_NAME, PRESENCE_TYPE, channelLimits, matchChannel } from "@plitzi/sdk-shared/realtime";
7
8
  //#region src/modules/realtime/handlers.ts
8
9
  /** Topics one connection may hold. A page shows a handful; past this it is somebody enumerating. */
9
10
  var MAX_TOPICS = 8;
@@ -91,7 +92,8 @@ var admit = async (req, resolveChannels, grants) => {
91
92
  } } : {},
92
93
  accepted,
93
94
  granted,
94
- refused
95
+ refused,
96
+ observer: Object.hasOwn(readCookies(req), OBSERVER_COOKIE_NAME)
95
97
  };
96
98
  };
97
99
  /** A new connection for what was admitted, speaking through `send`. */
@@ -103,6 +105,7 @@ var connectionFor = (admission, send, end) => ({
103
105
  topics: admission.accepted,
104
106
  grants: admission.granted,
105
107
  announced: /* @__PURE__ */ new Set(),
108
+ observer: admission.observer,
106
109
  sent: /* @__PURE__ */ new Map(),
107
110
  send,
108
111
  end
@@ -123,7 +126,7 @@ var withinRate = (connection, topic, perSecond) => {
123
126
  * may send on, within its size and its rate — and stamped by the server with who sent it and when, whatever the
124
127
  * message said.
125
128
  */
126
- var publishFrom = async (hub, connection, { topic, type, data }) => {
129
+ var publishFrom = async (hub, connection, { topic, type, data, echo }) => {
127
130
  const name = typeof topic === "string" ? topic : "";
128
131
  const declaration = connection.topics.get(name);
129
132
  if (!declaration) return {
@@ -136,6 +139,7 @@ var publishFrom = async (hub, connection, { topic, type, data }) => {
136
139
  error: `Only the server publishes on "${name}"`,
137
140
  reason: "server_only"
138
141
  };
142
+ if (connection.observer) return { status: 204 };
139
143
  const kind = typeof type === "string" ? type : "";
140
144
  const presence = kind === PRESENCE_TYPE && declaration.presence === true;
141
145
  if (!presence && !TYPE.test(kind)) return {
@@ -157,7 +161,7 @@ var publishFrom = async (hub, connection, { topic, type, data }) => {
157
161
  };
158
162
  if (presence) connection.announced.add(name);
159
163
  try {
160
- await hub.publish(connection.space, hub.from(connection, name, kind, value));
164
+ await hub.publish(connection.space, hub.from(connection, name, kind, value, echo === true && !presence));
161
165
  } catch (error) {
162
166
  warnRealtime("a message was not delivered")(error);
163
167
  return {
@@ -18,6 +18,8 @@ export type RealtimeConnection = {
18
18
  grants: Map<string, string>;
19
19
  /** The topics it announced itself on, which hear `$leave` when it goes. */
20
20
  announced: Set<string>;
21
+ /** A page that watches (`page check`, `page shot`): it hears its topics, arrives on none and says nothing on any. */
22
+ observer: boolean;
21
23
  /** Messages sent this second, by topic — the rate a channel allows is per connection, and a connection is here. */
22
24
  sent: Map<string, {
23
25
  second: number;
@@ -38,7 +40,7 @@ export type RealtimeHub = ReturnType<typeof createRealtimeHub>;
38
40
  */
39
41
  export declare const createRealtimeHub: (pubsub: PubSubAdapter) => {
40
42
  publish: (space: RealtimeSpace, message: RealtimeMessage) => Promise<void>;
41
- from: (connection: RealtimeConnection, topic: string, type: string, data: unknown) => RealtimeMessage;
43
+ from: (connection: RealtimeConnection, topic: string, type: string, data: unknown, echo?: boolean) => RealtimeMessage;
42
44
  find: (token: string) => RealtimeConnection | undefined;
43
45
  /**
44
46
  * Lets go of `topic` every connection, on every replica, that opened it with `grant` — or with any grant, naming
@@ -65,14 +65,18 @@ var createRealtimeHub = (pubsub) => {
65
65
  await (await entry.release)();
66
66
  }
67
67
  };
68
- /** A stamped message from this connection: `from` and `user` are the server's, whatever was sent. */
69
- const from = (connection, topic, type, data) => ({
68
+ /**
69
+ * A stamped message from this connection: `from` and `user` are the server's, whatever was sent. `echo` carries the
70
+ * sender's asking to hear it too, which its page reads.
71
+ */
72
+ const from = (connection, topic, type, data, echo = false) => ({
70
73
  topic,
71
74
  type,
72
75
  data,
73
76
  from: connection.id,
74
77
  ...connection.user ? { user: connection.user } : {},
75
- at: Date.now()
78
+ at: Date.now(),
79
+ ...echo ? { echo: true } : {}
76
80
  });
77
81
  /**
78
82
  * Lets `connection` go of `topic`: it is told, the others on a presence channel hear it leave, and it no longer
@@ -111,7 +115,7 @@ var createRealtimeHub = (pubsub) => {
111
115
  byToken.set(connection.token, connection);
112
116
  for (const [topic, declaration] of connection.topics) {
113
117
  await join(connection, topic);
114
- if (declaration.presence) await publish(connection.space, from(connection, topic, JOIN_TYPE, null));
118
+ if (declaration.presence && !connection.observer) await publish(connection.space, from(connection, topic, JOIN_TYPE, null));
115
119
  }
116
120
  },
117
121
  /** Unsubscribes a connection that went away, and tells the channels it announced itself on. */
@@ -1,5 +1,6 @@
1
1
  import { requestFlagOverrides } from "../../helpers/flagOverrides.js";
2
2
  import { authorizesDebugging } from "../../helpers/debugAuthorization.js";
3
+ import { SPACE_TOKEN_PARAM } from "../../core/auth/credentials.js";
3
4
  import { realtimeModuleFor } from "../realtime/index.js";
4
5
  import { matchRscPage } from "../rsc/matchRscPage.js";
5
6
  import { ssrPaintedCookieName } from "../../helpers/paintedCookie.js";
@@ -14,7 +15,6 @@ import { publishSpaceDocument } from "./spaceDocument.js";
14
15
  import { registerExternalPlugins } from "./registerExternalPlugins.js";
15
16
  import { reportMissingPlugins } from "./reportMissingPlugins.js";
16
17
  import { resolvePageSeo } from "./resolvePageSeo.js";
17
- import { SPACE_TOKEN_PARAM } from "../../core/auth/credentials.js";
18
18
  import { imagesPathOf } from "../../core/http/stages/images.js";
19
19
  import { sdkAssetVersion } from "../../core/sdkAssets.js";
20
20
  import { buildServerInfo } from "../../helpers/buildServerInfo.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plitzi/sdk-server",
3
- "version": "0.38.10",
3
+ "version": "0.38.12",
4
4
  "license": "AGPL-3.0",
5
5
  "files": [
6
6
  "dist",
@@ -96,8 +96,8 @@
96
96
  "./dist/env.js"
97
97
  ],
98
98
  "dependencies": {
99
- "@plitzi/plitzi-sdk": "0.38.10",
100
- "@plitzi/sdk-shared": "0.38.10",
99
+ "@plitzi/plitzi-sdk": "0.38.12",
100
+ "@plitzi/sdk-shared": "0.38.12",
101
101
  "ejs": "^6.0.1",
102
102
  "esbuild": "^0.28.2",
103
103
  "jsonwebtoken": "^9.0.3",
@@ -19,6 +19,7 @@ const space: SpaceSpec = {
19
19
  pages: [{ id, name, slug: '', layout: { id: 'shell', slot: 'main' }, body: [] }] // slot: where the body goes
20
20
  }; // slug '' is home; 'products/:slug' takes a param
21
21
  pageFamily({ folder, layout, seoTitle, body: entry => [...] }, ENTRIES) // pages of one shape, from data; ids scoped per page
22
+ pageFolders: [{ id: 'docs', name: 'Docs' }] // on the space: a `folder` is declared here, /docs/…
22
23
  const { schema, style, warnings, suggestions } = authorSpace(space, { plugins: [declaration] });
23
24
  ```
24
25
 
@@ -43,6 +44,7 @@ svg(svgFile(new URL('./logo.svg', import.meta.url))) // a file, compacted: svgF
43
44
  fontAwesome({ icon: 'fa-solid fa-xmark' })
44
45
  list({ id: 'rows', items: 'p.data.rows', row: 'row-card' }) // a <ul>; a component per row, its root an <li>
45
46
  list({ id: 'rows', items: 'p.data.rows', row: r => listItem({ children: [text({ from: `${r.item}.title` })] }) })
47
+ // typed items (site.data.rows): r.item is a path — from: r.item.title
46
48
  carousel({ id: 'hero', items: 'p.data.slides', autoplay: 5000, row: 'slide-card', children: [/* carouselNext('hero') arrows */] })
47
49
  // rows follow their item's `id`, or `itemKey: 'slug'`
48
50
  apiContainer({ id: 'p', query: '/data/x.json', cache: true, children }) // publishes p.data; no box of its own
@@ -53,7 +55,7 @@ form({ id, managedByInteractions: true, flows, children })
53
55
  formControl({ name: 'email', label: 'Email', subType: 'email', required: true }) // optional without required; select: options: [{ label, value }]
54
56
  modalContainer({ id, visible: false, title, children })
55
57
  component('card', { props: { title }, children: { body: [ … ] } })
56
- custom({ renderType: 'myPlugin' }); defineElement<Props>(declaration) // a plugin
58
+ const seats = defineElement(seatsDeclaration); seats({ id }) // a plugin
57
59
  ```
58
60
 
59
61
  ## Styles
@@ -63,7 +65,7 @@ const card = styles('card', { padding: 24, borderRadius: 16 }); // shorth
63
65
  styles('title', { fontSize: { desktop: '48px', compact: '32px' }, fontWeight: 700 }); // one property per breakpoint
64
66
  styles('card', { css: { desktop: { … }, compact: { … } }, states: { hover: { … } }, variants: { active: { … } } });
65
67
  container({ class: card }); // a shared class…
66
- container({ id: 'hero', class: [card, { opacity: '0.5' }] }); // …plus one thing (needs an id)
68
+ container({ class: [card, { opacity: '0.5' }] }); // …plus one thing
67
69
  container({ css: { padding: '8px' } }); // rules of its own — never with `class`
68
70
  ```
69
71
 
@@ -11,18 +11,20 @@ import {
11
11
  onClick,
12
12
  setState
13
13
  } from '@plitzi/sdk-authoring';
14
+ import { definePlugin } from '@plitzi/sdk-authoring/plugin';
14
15
 
15
16
  import type { PluginDeclarationData, SpaceSpec } from '@plitzi/sdk-authoring';
16
17
 
17
- // In a project: `import declaration from './plugins/seatPicker/declaration';`
18
- const declaration = {
18
+ // In a project, `src/plugins/SeatPicker/declaration.ts` — `export default definePlugin<SeatPickerAttributes>()({ … })`.
19
+ const declaration = definePlugin<{ rows?: number }>()({
19
20
  type: 'seatPicker',
20
- triggers: { onPick: { action: 'onPick', title: 'On Pick', type: 'trigger', params: {}, preview: { seat: '' } } },
21
- callbacks: { reset: { action: 'reset', title: 'Reset', type: 'callback', params: {} } },
22
- content: { attributes: { rows: 10 }, definition: { label: 'Seat Picker' } }
23
- } as const;
21
+ label: 'Seat Picker',
22
+ attributes: { rows: 10 },
23
+ triggers: { onPick: { preview: { seat: '' } } },
24
+ callbacks: { reset: {} }
25
+ });
24
26
 
25
- const seatPicker = defineElement<{ rows?: number }>(declaration);
27
+ const seatPicker = defineElement(declaration);
26
28
 
27
29
  // `authorSpace(recipe, { plugins })` — in a project, every `src/plugins/<Name>/declaration.ts`, found by folder.
28
30
  export const plugins: PluginDeclarationData[] = [declaration];
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Style without copies: colours are tokens with a light and a dark value, a look used twice is a class, "this class
3
- * plus one thing" is rules on top of it (`class: [card, { … }]`, which needs an `id`), and a rule for tablet AND phone
3
+ * plus one thing" is rules on top of it (`class: [card, { … }]`), and a rule for tablet AND phone
4
4
  * is written once under `compact`.
5
5
  */
6
6
  import { container, heading, styles, text } from '@plitzi/sdk-authoring';
@@ -38,6 +38,7 @@ image({ src: '/grain.png', decorative: true }) // an orname
38
38
  image({ decorative: true, bind: { src: 'post.record.cover' } }) // a cover under its own headline adds nothing
39
39
  image({ bind: { src: 'row.item.cover', alt: 'row.item.title' } }) // …unless it is alone in a link: then it names it
40
40
  fontAwesome({ icon: 'fa-solid fa-triangle-exclamation', label: 'Overdue' }) // an icon that says something alone
41
+ svg(MARK, { class: logoMark }) // no `label`: an svg or an icon decorates
41
42
  text('福', { decorative: true }) // a seal or a mark drawn for the look alone
42
43
  list({ label: 'Kinds of capsule', … }) // a group of options is named, as a group
43
44
  ```
@@ -47,7 +47,7 @@ The space is not written until these are fixed.
47
47
  | `channel-grant` | a topic of a private channel opened with no grant to it | the grant the message names, or a public channel |
48
48
  | `channel-topic` | a `channel` element with no topic | a topic a channel of the space covers |
49
49
  | `children-in-leaf` | children on a type that renders only its own attributes (`heading`, `text`, `image`…) | a `container` — for a heading made of parts, `container({ subType: 'h1', children })` |
50
- | `class-and-css` | an element wearing a shared class AND `css` or `states` of its own — it has one base selector | the rules on top of the class: `class: [card, { opacity: '0.5' }]` (needs an `id`) — or into the class |
50
+ | `class-and-css` | an element wearing a shared class AND `css` or `states` of its own — it has one base selector | the rules on top of the class: `class: [card, { opacity: '0.5' }]` — or into the class |
51
51
  | `class-and-selector` | an element with a shared class and a `selector` of its own | drop one: a shared class IS its selector |
52
52
  | `class-conflict` | two different declarations for one class name | rename one, or make them agree |
53
53
  | `class-listed-under-other-name` | a `styles()` declaration listed in `classes` under another name | list it under its own name |
@@ -64,6 +64,7 @@ The space is not written until these are fixed.
64
64
  | `css-property-twice` | one property written twice in a rule set — `paddingTop` beside `'padding-top'` — so one would silently win | keep one |
65
65
  | `css-property-unknown` | a CSS property that does not exist | the property it suggests; a custom property starts with `--` |
66
66
  | `css-value` | an empty CSS value, or one with `;` or `{}` | one value per property; leave a property out instead of writing it empty |
67
+ | `declared-callback-target` | a `declaredCallback` target with a key beside `on` and `params` — read nowhere, so the step runs without it | the value in `params`: `{ on: 'calendar', params: { date } }` |
67
68
  | `element-ids-target` | a step refreshing containers by id (`invalidateElements`, `invalidateQueries({ elements })`) naming one the space does not have, or one that is not an `apiContainer` | the id of the `apiContainer` whose data the step changes: `invalidateElements: ['posts']` |
68
69
  | `element-load-strategy` | a `loadStrategy` the element does not take | one of the values the message lists |
69
70
  | `element-rejected` | an element the schema refused to hold | the problem listed with it |
@@ -79,7 +80,7 @@ The space is not written until these are fixed.
79
80
  | `flow-empty` | a flow with no steps | `[onClick(), setState({ … })]` |
80
81
  | `flow-without-trigger` | a flow whose first step is not the event that runs it | `[onClick(), setState(…)]` |
81
82
  | `folder-cycle` | a page folder inside itself | a `parent` that leads to the top |
82
- | `folder-undeclared` | a page, a layout or a folder filed in a folder the space does not declare | declare the folder, or the name it suggests |
83
+ | `folder-undeclared` | a page, a layout or a folder filed in a folder the space does not declare | declare it on the space — `pageFolders: [{ id: 'docs', name: 'Docs' }]`, its slug starting its pages' addresses — or use the name it suggests |
83
84
  | `font-invalid` | a font the space cannot load as declared | the field the message names |
84
85
  | `format-unknown` | an `as` that names no format of the space's `formats` | declare it once — `formats: { price: "{{ source\|currency('USD') }}" }` — or write the template in its place |
85
86
  | `from-and-bind` | the main attribute bound twice, with `from` and in `bind` | keep `from`, and give `bind` the other attributes |
@@ -97,7 +98,6 @@ The space is not written until these are fixed.
97
98
  | `list-without-items` | a controlled list with nothing to render | `items: [ … ]` or `bind: { items: 'provider.data.rows' }` |
98
99
  | `loading-slot-unknown` | an `apiContainer`'s `loadingSlot` that is not the id of one of its children | the id of the child to show until the first answer — `loadingSlot: 'catalog-skeleton'` beside that child |
99
100
  | `modifier-count` | more than one set of rules in a class list | one set, after the classes: `class: [card, { opacity: '0.5', 'margin-top': '8px' }]` |
100
- | `modifier-without-id` | rules on top of a class on an element with no `id` — the id names the class they become | `id: 'hero-bg'` |
101
101
  | `motion-invalid` | a `motion` with a preset, a trigger or a timing the page cannot play | `motion: { enter: 'fade-up', on: 'view' }` — the message lists the presets |
102
102
  | `motion-no-tag` | a `motion` on an element with no tag of its own — nothing to move | the motion on what it wraps, or a `subType` on it |
103
103
  | `no-pages` | a space with no pages | `pages: [{ id: 'home', name: 'Home', slug: '', body: [] }]` |
@@ -132,6 +132,7 @@ The space is not written until these are fixed.
132
132
  | `server-provider-in-component` | a `runtime: 'server'` element inside a component: the page server resolves a page's and its layouts', never a component's | put the provider on the page, around the instance, and hand the component its rows as a prop |
133
133
  | `setting-misplaced` | `settings.computed` or `settings.channels` written inside `settings` | `computed` and `channels` at the top of the space |
134
134
  | `slot-children` | children handed to an instance outside its slots | `children: { slotName: [ … ] }`; a component with no slots takes no children |
135
+ | `slot-not-class` | a `slots` entry that is not a class — rules written in place, or nothing — which a slot cannot wear | the rules as a `styles()` declaration, handed to the slot beside its other classes |
135
136
  | `slot-unknown` | a component slot that is not an element of its tree | the id of an element inside the component — usually an empty container |
136
137
  | `source-field-unknown` | a path into a typed source that its sample does not have | read a field the sample has, or add the field to the sample |
137
138
  | `state-key-list` | `settings.transientState` / `paintedState` that is not a list of top-level state keys | `transientState: ['demoStep']` — a dotted path names its top key |
@@ -146,6 +147,7 @@ The space is not written until these are fixed.
146
147
  | `style-pseudo-unknown` | a pseudo-element a class cannot dress | one of `before`, `after`, `marker`, `placeholder`, `first-letter`, `first-line`, `selection` — without the `::` |
147
148
  | `style-state-unknown` | a state a selector does not react to | one of the states it lists (`hover`, `focus`, `active`…) |
148
149
  | `svg-not-svg` | an `svg` whose `content` is not one `<svg>…</svg>` — it draws nothing | the SVG markup alone; HTML around it goes in a `blockHtml` |
150
+ | `template-in-value` | a `{{ }}` inside an attribute that is an object or a list — a provider's `input`, a list's `items`, a plugin's settings: only an attribute that is text is evaluated | a binding on the attribute (`bindTemplate(name, source, template, { returns: 'value' })`); for a provider's `input`, nothing for a route or query param — the action is already handed them as `input.<name>` |
149
151
  | `template-short-source` | a source named without its prefix inside a template or a step | the full name: `apiContainer_stats`, `list_rows` |
150
152
  | `template-source-out-of-scope` | a template reading the source of an element it is not inside | move the element inside it, or share the value through `state` |
151
153
  | `template-text-into-value` | a template, which renders text, feeding an attribute that holds a list or an object | `bindTemplate('items', source, '{{ … }}', { returns: 'value' })` |
@@ -232,11 +234,13 @@ Nothing is wrong: the page renders as written. Each is a shorter way to the same
232
234
  | `custom-css-notifications` | the toasts or their icon, close button or progress bar dressed with `.Toastify__*` rules in `customCss` | `notifications: { font, fontSize, fontWeight, minHeight, iconSize, closeColor, progressHeight, … }` |
233
235
  | `custom-css-sdk-default` | `customCss` repeating what the SDK already does for every space — less motion when asked, the theme toggle showing one icon | nothing: remove it |
234
236
  | `custom-css-slot` | a part of a built-in element dressed in `customCss` by the SDK's markup — a form control's `<input>`, a pager's buttons, a heading inside a markdown | a class on the slot of that part — `slots: { field: input }`, `elements.<type>.slots` — with its states |
237
+ | `element-color-inherited` | `elements` giving a type — a heading, a text, a link — `color: inherit` or the page's own colour, which it takes by itself | remove it, and name the colour once on `elements.page`: every element reads in the page’s colour, a link’s words included |
235
238
  | `heavy-animation` | keyframes animating what the browser repaints or lays out again on every frame — a size, a position, a blur, a shadow, or a colour in a loop — which stutters whenever the page is busy, most of all while it loads | `opacity` and `transform`: a size or a position is `translate`/`scale`, a blur or a shadow is the `opacity` of a layer carrying it; a loop that must animate anything else starts `paused` and runs under `[data-hydrated]` |
236
239
  | `literal-colour` | a colour written out in a class where the space has a token of that value — it stays put in the dark scheme | `var(--token)` when it should follow the scheme; a token of one value of its own when it must stay the same in both |
237
240
  | `not-found-page` | a space with no page of its own for an address no other page answers — a plain one was added, in the home page’s layout, sent with status 404 | a page whose slug is `'*'`: `{ id: 'not-found', name: 'Not found', slug: '*', layout: …, body: [...] }` |
241
+ | `plugin-custom-host` | a `custom` element naming, by `renderType`, a plugin whose declaration the space was handed | place it from its declaration — `const seatPicker = defineElement(declaration)`, then `seatPicker({ id, … })`: typed by what it declares (a `definePlugin` declaration carries its attributes), the element the builder adds too |
238
242
  | `repeated-on-pages` | the same block — a header, a footer, a side panel — written into several pages | a layout holding it once (`layouts`, and `layout: { id, slot }` on each page) — a component when only some pages of a layout carry it; a link marks its own page with the `current` state |
239
- | `repeated-shape` | the same structure written again and again with different words — cards, rows, tiles | a component with props (`components`, `component(id, { props })`), or one `list` when they are rows of data side by side — a few cards a person rewords on the canvas can stay cards |
243
+ | `repeated-shape` | the same structure written again and again with different words — cards, rows, tiles; copies that bind different data, read their own template or render on the server are controls, not repeats | a component with props (`components`, `component(id, { props })`), or one `list` when they are rows of data side by side — a few cards a person rewords on the canvas can stay cards |
240
244
  | `unused-class` | a class declared in `classes` that no element, binding, flow or other class names | remove it from `classes` — or wear it where it was meant to go |
241
245
  | `unused-component` | a component no page, layout or other component places | remove it from `components`, or place it where it was meant to go |
242
246
  | `unused-token` | a token of `variables` no `var(--…)` reads — not a class, an element, `customCss` nor another token | remove it, or write it where its colour is written out |
@@ -18,6 +18,8 @@ the value), or a format the space names once in `formats: { price: "{{ source|cu
18
18
  `bindTemplate(to, source, template, { category?, returns? })` is the computed attribute — a template over the value
19
19
  at `source`. `returns: 'value'` hands over what a single `{{ expression }}` evaluates to instead of its text: a list's
20
20
  `items`, a number, a flag (see [lists](lists.md)).
21
+ A colour from data binds a custom property — `[{ to: '--who', source, category: 'style' }]` — that the class reads
22
+ (`var(--who, var(--muted))`); a value with `;` or braces is not written.
21
23
 
22
24
  **A source names the id you gave the element** — `'posts.title'`, `'postList.item.cover'` — and the prefix is
23
25
  completed from what the element publishes (`apiContainer_posts`, `list_postList`; a `form` publishes under
@@ -72,6 +74,8 @@ apiContainer({ id: 'board', runtime: 'server', action: 'queue-board' })
72
74
  `"{{ source ? apiUrl ~ '/workspaces/' ~ source ~ '/stats' : '' }}"` with `source: 'state.workspace.id'`. A server
73
75
  provider's bound `input` (`bindTemplate('input', 'state.c', '{{ { country: source } }}', { returns: 'value' })`)
74
76
  asks again on every change; `reloadApi('p', { … })` asks once.
77
+ - An action is handed the page's route and query params as input (`r/:room` → `input.room`). A written `input` is
78
+ sent as is: a `{{ }}` in it is refused (`template-in-value`) — bind it.
75
79
  - `isLoading` is true while it is asked again, in either runtime. A newer ask drops the older one; `cancelApi('p')` is a
76
80
  STOP: what is shown stays.
77
81
  - `runtime: 'server'` is resolved by the page server — server data is on unless a space says `rsc: { enabled: false }`.
@@ -106,9 +110,8 @@ server action answering only what is shown, its `access` checking who asks.
106
110
  - **Refreshed by writes:** a `webHook` that is not a GET refreshes every request to its own site by default
107
111
  (`invalidateQueries: 'origin'`); a `runServerAction` refreshes everything (`'all'`). Narrow it with
108
112
  `'elements'` + `invalidateElements: ['orders']`, or stop it with `'none'`.
109
- - **Say `'none'` when a refresh would move the page on.** A page that decides where to go from a provider's answer
110
- ("profile complete → leave") will act on the refreshed answer in the middle of a multi-step form that saves as it
111
- goes, and skip the last step.
113
+ - **Say `'none'` when a refresh would move the page on.** A page that leaves on a provider's answer ("profile complete
114
+ → leave") would leave in the middle of a multi-step form that saves as it goes.
112
115
 
113
116
  ## Visibility
114
117
 
@@ -156,10 +159,9 @@ Rules for a condition's template:
156
159
  `'true'` or `'false'` is written as it says. A plain source, or a template that answers nothing (`''`), writes
157
160
  nothing: the element stays as it started — hidden for `visible`, shown for a binding written by hand.
158
161
  - **So a hiding flag is `visible: '!state.folded'`** (or a binding with no `visible`): on screen before anybody has set
159
- it. A condition that answers "not known yet" with `'false'` from a value that IS there — a "folded" read off
160
- something always set — hid every label of a sidebar for everybody.
161
- - **Hide the element itself.** Never wrap it in a container that is shown while the inner one is hidden: an empty
162
- visible wrapper still takes a slot in its parent's `gap`, and leaves a hole in the page.
162
+ it.
163
+ - **Hide the element itself**, not inside a wrapper left shown: an empty wrapper still takes a slot in its parent's
164
+ `gap`, a hole in the page.
163
165
 
164
166
  ### Loading, empty, error
165
167
 
@@ -183,9 +185,8 @@ shows on every load. Test for "arrived and empty" (`is defined and … is empty`
183
185
 
184
186
  ## Never ask the data for an opposite
185
187
 
186
- Both sides of one question are `visible: 'x'` and `visible: '!x'`, not an `x` and a `notX` in the server's answer.
187
- `!` reads a boolean that travelled as text (`"false"`, `"0"`) correctly. A three-state condition
188
- (`Boolean(post) && !canEdit`) still belongs where the data is made.
188
+ Both sides of one question are `visible: 'x'` and `visible: '!x'`, never a `notX` in the answer — `!` reads `"false"`
189
+ and `"0"` as false. A three-state condition still belongs where the data is made.
189
190
 
190
191
  ## State that outlives a reload
191
192
 
@@ -73,7 +73,7 @@ indented with space below it: `{ margin: '0px', padding: '0px' }` on its class t
73
73
  children sit in the parent's layout and a `class` on it styles nothing (`STYLE_WITHOUT_TAG`). Put the layout on the
74
74
  parent, or give it `attributes: { subType: 'div' }`.
75
75
 
76
- A plugin's type is authored the same way: `defineElement<Props>({ type, … })` — see [plugins](plugins.md).
76
+ A plugin's type is authored the same way: `defineElement(declaration)` — see [plugins](plugins.md).
77
77
 
78
78
  **What a type takes:** `npx @plitzi/cli explain formControl`, or the exported catalogues ([validation.md](validation.md)).
79
79