@plitzi/sdk-server 0.38.4 → 0.38.5

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 (116) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/README.md +126 -17
  3. package/dist/actions.d.ts +8 -5
  4. package/dist/actions.js +3 -1
  5. package/dist/adapters/jsonAdapters.d.ts +1 -1
  6. package/dist/adapters/jsonAdapters.js +16 -3
  7. package/dist/core/configSeam.js +20 -4
  8. package/dist/core/createServer.js +3 -2
  9. package/dist/core/handlers/socialRouteHandlers.js +6 -1
  10. package/dist/core/http/stages/devReload.d.ts +11 -0
  11. package/dist/core/http/stages/devReload.js +17 -0
  12. package/dist/core/server/baseServer.d.ts +2 -0
  13. package/dist/core/server/baseServer.js +3 -0
  14. package/dist/core/server/pageServer.js +18 -0
  15. package/dist/core/services/registry.js +2 -0
  16. package/dist/core/services/rsc.js +1 -1
  17. package/dist/core/services/spaceDocument.d.ts +2 -0
  18. package/dist/core/services/spaceDocument.js +11 -0
  19. package/dist/functions-api.d.ts +6 -1
  20. package/dist/functionsRunner.d.ts +5 -0
  21. package/dist/functionsRunner.js +2 -1
  22. package/dist/helpers/hydrationPayload.d.ts +25 -4
  23. package/dist/helpers/hydrationPayload.js +36 -30
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +2 -2
  26. package/dist/modules/actions/index.d.ts +11 -2
  27. package/dist/modules/actions/index.js +29 -7
  28. package/dist/modules/actions/runtime/fileKv.d.ts +28 -0
  29. package/dist/modules/actions/runtime/fileKv.js +95 -0
  30. package/dist/modules/actions/runtime/fileKv.test.d.ts +1 -0
  31. package/dist/modules/actions/runtime/memoryKv.d.ts +23 -0
  32. package/dist/modules/actions/runtime/memoryKv.js +39 -24
  33. package/dist/modules/actions/runtime/namespaceKv.d.ts +2 -0
  34. package/dist/modules/actions/runtime/namespaceKv.js +5 -3
  35. package/dist/modules/actions/runtime/renderResolver.d.ts +3 -2
  36. package/dist/modules/actions/runtime/renderResolver.js +13 -9
  37. package/dist/modules/actions/runtime/renderShare.d.ts +7 -24
  38. package/dist/modules/actions/runtime/renderShare.js +47 -13
  39. package/dist/modules/actions/runtime/runAction.js +15 -1
  40. package/dist/modules/actions/runtime/scope.js +16 -1
  41. package/dist/modules/actions/runtime/signing.js +4 -1
  42. package/dist/modules/actions/runtime/taskContext.d.ts +7 -1
  43. package/dist/modules/actions/runtime/taskContext.js +6 -2
  44. package/dist/modules/actions/tasks/registry.d.ts +3 -1
  45. package/dist/modules/actions/tasks/registry.js +15 -1
  46. package/dist/modules/actions/types.d.ts +15 -2
  47. package/dist/modules/functions/config.d.ts +6 -0
  48. package/dist/modules/functions/context.d.ts +4 -2
  49. package/dist/modules/functions/context.js +43 -21
  50. package/dist/modules/functions/load.d.ts +8 -1
  51. package/dist/modules/functions/load.js +8 -4
  52. package/dist/modules/functions/manifest.d.ts +4 -1
  53. package/dist/modules/functions/manifest.js +14 -2
  54. package/dist/modules/functions/native.d.ts +2 -1
  55. package/dist/modules/functions/native.js +2 -2
  56. package/dist/modules/functions/plugins.test.d.ts +1 -0
  57. package/dist/modules/functions/routes.d.ts +8 -3
  58. package/dist/modules/functions/routes.js +41 -21
  59. package/dist/modules/functions/scope.d.ts +30 -0
  60. package/dist/modules/functions/scope.js +28 -0
  61. package/dist/modules/functions/space.d.ts +10 -5
  62. package/dist/modules/functions/space.js +37 -15
  63. package/dist/modules/rsc/connectorRscData.d.ts +3 -1
  64. package/dist/modules/rsc/connectorRscData.js +8 -4
  65. package/dist/modules/rsc/handler.d.ts +3 -16
  66. package/dist/modules/rsc/handler.js +10 -4
  67. package/dist/modules/rsc/publicFileResolver.d.ts +15 -12
  68. package/dist/modules/rsc/publicFileResolver.js +56 -24
  69. package/dist/modules/rsc/resolveRscData.d.ts +3 -1
  70. package/dist/modules/rsc/resolveRscData.js +8 -5
  71. package/dist/modules/runtime/bundle.d.ts +5 -0
  72. package/dist/modules/runtime/bundle.js +20 -5
  73. package/dist/modules/sqlite/kv.d.ts +34 -0
  74. package/dist/modules/sqlite/kv.js +99 -0
  75. package/dist/modules/sqlite/kv.test.d.ts +1 -0
  76. package/dist/modules/ssr/prepareRender.js +45 -6
  77. package/dist/modules/ssr/spaceDocument.d.ts +25 -0
  78. package/dist/modules/ssr/spaceDocument.js +72 -0
  79. package/dist/modules/ssr/spaceDocument.test.d.ts +1 -0
  80. package/dist/modules/ssr/testing/offlineData.d.ts +5 -0
  81. package/dist/modules/ssr/views/template.ejs +122 -20
  82. package/dist/plugins/compile.js +6 -21
  83. package/dist/plugins/compile.test.d.ts +1 -0
  84. package/dist/plugins/copy.d.ts +5 -0
  85. package/dist/plugins/copy.js +16 -1
  86. package/dist/plugins/copy.test.d.ts +1 -0
  87. package/dist/plugins/manager.d.ts +13 -0
  88. package/dist/plugins/manager.js +40 -11
  89. package/dist/plugins/watch.d.ts +12 -0
  90. package/dist/plugins/watch.js +79 -0
  91. package/dist/runtime.d.ts +1 -1
  92. package/dist/runtime.js +2 -2
  93. package/dist/sqlite.d.ts +17 -0
  94. package/dist/sqlite.js +2 -0
  95. package/package.json +8 -3
  96. package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
  97. package/skills/plitzi-authoring/SKILL.md +20 -19
  98. package/skills/plitzi-authoring/recipes/plugin.ts +1 -1
  99. package/skills/plitzi-authoring/recipes/server-data.ts +3 -3
  100. package/skills/plitzi-authoring/recipes/show-data.ts +3 -2
  101. package/skills/plitzi-authoring/recipes/typed-data.ts +1 -1
  102. package/skills/plitzi-authoring/reference/authoring-errors.md +8 -2
  103. package/skills/plitzi-authoring/reference/colours-and-motion.md +3 -1
  104. package/skills/plitzi-authoring/reference/data-and-visibility.md +24 -22
  105. package/skills/plitzi-authoring/reference/drawing.md +24 -0
  106. package/skills/plitzi-authoring/reference/efficiency.md +19 -2
  107. package/skills/plitzi-authoring/reference/flows.md +3 -0
  108. package/skills/plitzi-authoring/reference/layouts.md +8 -23
  109. package/skills/plitzi-authoring/reference/performance.md +2 -2
  110. package/skills/plitzi-authoring/reference/plugins.md +51 -38
  111. package/skills/plitzi-authoring/reference/review-checklist.md +2 -0
  112. package/skills/plitzi-authoring/reference/structure.md +32 -1
  113. package/skills/plitzi-authoring/reference/templates.md +5 -2
  114. package/skills/plitzi-authoring/reference/testing.md +9 -2
  115. package/skills/plitzi-authoring/reference/typed-sources.md +3 -1
  116. package/skills/plitzi-authoring/reference/validation.md +1 -1
@@ -1,6 +1,6 @@
1
1
  import { createActionResolver } from "../actions/runtime/renderResolver.js";
2
2
  import { createConnectorResolver } from "../connectors/resolver.js";
3
- import { publicFileResolver } from "./publicFileResolver.js";
3
+ import { dataFileResolver, dataLookupResolver, publicFileResolver } from "./publicFileResolver.js";
4
4
  import { resolveRscData } from "./resolveRscData.js";
5
5
  //#region src/modules/rsc/connectorRscData.ts
6
6
  /**
@@ -18,10 +18,12 @@ import { resolveRscData } from "./resolveRscData.js";
18
18
  *
19
19
  * A deployment that resolves server elements some other way still supplies its own `getRscData`, and that one wins.
20
20
  */
21
- var connectorRscData = ({ connectors, actions, publicDir, elementTimeoutMs }) => {
21
+ var connectorRscData = ({ connectors, actions, publicDir, dataDir, elementTimeoutMs }) => {
22
22
  const resolveConnector = connectors ? createConnectorResolver(connectors) : void 0;
23
23
  const resolveAction = actions ? createActionResolver(actions.lookups, actions.module) : void 0;
24
24
  const resolvePublicFile = publicDir ? publicFileResolver(publicDir) : void 0;
25
+ const dataLookup = actions?.lookups.getData;
26
+ const resolveDataFile = dataDir ? dataFileResolver(dataDir) : dataLookup ? dataLookupResolver((spaceId, at) => dataLookup(spaceId, at)) : void 0;
25
27
  /**
26
28
  * An element names ONE producer, and which one decides how its data is fetched.
27
29
  *
@@ -33,9 +35,10 @@ var connectorRscData = ({ connectors, actions, publicDir, elementTimeoutMs }) =>
33
35
  const attributes = context.element.attributes;
34
36
  if (attributes.connector) return resolveConnector ? resolveConnector(context) : void 0;
35
37
  if (attributes.action) return resolveAction ? resolveAction(context) : void 0;
36
- return resolvePublicFile ? resolvePublicFile(context) : void 0;
38
+ const fromData = resolveDataFile ? await resolveDataFile(context) : void 0;
39
+ return fromData !== void 0 ? fromData : resolvePublicFile ? resolvePublicFile(context) : void 0;
37
40
  };
38
- return async ({ req, spaceId, environment, user, ids, loadOfflineData, flagOverrides }) => {
41
+ return async ({ req, spaceId, environment, user, ids, loadOfflineData, flagOverrides, signal }) => {
39
42
  const offlineData = await loadOfflineData();
40
43
  if (!offlineData || offlineData.schema.rsc?.enabled === false) return {};
41
44
  return resolveRscData({
@@ -47,6 +50,7 @@ var connectorRscData = ({ connectors, actions, publicDir, elementTimeoutMs }) =>
47
50
  ids,
48
51
  resolveElement,
49
52
  ...flagOverrides ? { flagOverrides } : {},
53
+ ...signal ? { signal } : {},
50
54
  ...elementTimeoutMs === void 0 ? {} : { timeoutMs: elementTimeoutMs }
51
55
  });
52
56
  };
@@ -1,21 +1,6 @@
1
1
  import { TtlCache } from '../../helpers/cache';
2
2
  import { PluginManager } from '../../plugins/manager';
3
3
  import { SSRPageServerConfig, SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
4
- /**
5
- * Rewrites the request to the page the browser is actually on.
6
- *
7
- * A refresh is issued against `/_rsc`, not against `/blog/my-post`, so resolving straight from `req.path` matches
8
- * nothing in `schema.pages` and every client-side refresh silently returns no data. The SDK sends the visitor's
9
- * location as `?location=`, and route params plus the page parameter are read from that instead.
10
- *
11
- * What a refresh asks for besides — the next page of a "load more", a search a provider was reloaded with — rides on
12
- * the query string beside `location`, and is read over the location's own query. It used to be dropped with the rest
13
- * of the endpoint's query, so a provider paged in place was answered its first page every time.
14
- *
15
- * Reflecting both is safe by construction: the location is matched against this space's own page list, and the rest
16
- * is what the same visitor could have put in the page's own address; the cache key is the whole request, so no answer
17
- * is served for a query it was not made for.
18
- */
19
4
  export declare const withPageLocation: (req: SSRRequest) => SSRRequest;
20
5
  /**
21
6
  * Handles GET /_rsc requests.
@@ -30,4 +15,6 @@ export declare const withPageLocation: (req: SSRRequest) => SSRRequest;
30
15
  * - Authenticated requests: Cache-Control: private, max-age=<ttl>
31
16
  * - Unauthenticated requests: Cache-Control: public, max-age=<ttl>
32
17
  */
33
- export declare const handleRsc: (req: SSRRequest, res: SSRResponseHelpers, config: SSRPageServerConfig, _pluginManager: PluginManager, cache?: TtlCache<string>) => Promise<void>;
18
+ export declare const handleRsc: (req: SSRRequest, res: SSRResponseHelpers, config: SSRPageServerConfig, _pluginManager: PluginManager, cache?: TtlCache<string>,
19
+ /** The request's own: a browser that stops waiting stops the elements it asked about. */
20
+ signal?: AbortSignal) => Promise<void>;
@@ -39,12 +39,16 @@ var ENDPOINT_PARAMS = /* @__PURE__ */ new Set(["location", "ids"]);
39
39
  * is what the same visitor could have put in the page's own address; the cache key is the whole request, so no answer
40
40
  * is served for a query it was not made for.
41
41
  */
42
+ /** What a refresh asked for beside the endpoint's own parameters. */
43
+ var refreshParams = (req) => Object.fromEntries(Object.entries(req.query).filter(([key]) => !ENDPOINT_PARAMS.has(key)));
42
44
  var withPageLocation = (req) => {
43
45
  const location = req.query.location;
44
46
  if (!location || !location.startsWith("/") || location.length > MAX_PAGE_LOCATION) return req;
45
47
  const url = new URL(location, `${req.protocol}://${req.hostname}`);
46
- const asked = Object.entries(req.query).filter(([key]) => !ENDPOINT_PARAMS.has(key));
47
- const query = Object.fromEntries([...url.searchParams.entries(), ...asked]);
48
+ const query = {
49
+ ...Object.fromEntries(url.searchParams.entries()),
50
+ ...refreshParams(req)
51
+ };
48
52
  const search = new URLSearchParams(query).toString();
49
53
  return {
50
54
  ...req,
@@ -67,7 +71,7 @@ var withPageLocation = (req) => {
67
71
  * - Authenticated requests: Cache-Control: private, max-age=<ttl>
68
72
  * - Unauthenticated requests: Cache-Control: public, max-age=<ttl>
69
73
  */
70
- var handleRsc = async (req, res, config, _pluginManager, cache) => {
74
+ var handleRsc = async (req, res, config, _pluginManager, cache, signal) => {
71
75
  if (!config.adapters.getRscData) {
72
76
  res.setStatus(501);
73
77
  res.send(JSON.stringify({ error: "getRscData adapter not configured" }));
@@ -83,6 +87,7 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
83
87
  const ids = idsRaw ? idsRaw.split(",").filter(Boolean).slice(0, 50).map((id) => id.slice(0, 128)) : void 0;
84
88
  const idsParam = ids?.join(",");
85
89
  const pageRequest = withPageLocation(req);
90
+ req.ctx.rscParams = refreshParams(req);
86
91
  /**
87
92
  * Whether this refresh belongs to somebody looking at a draft.
88
93
  *
@@ -130,7 +135,8 @@ var handleRsc = async (req, res, config, _pluginManager, cache) => {
130
135
  user: req.ctx.user,
131
136
  ids,
132
137
  loadOfflineData,
133
- flagOverrides
138
+ flagOverrides,
139
+ ...signal ? { signal } : {}
134
140
  });
135
141
  } catch (err) {
136
142
  serverLog.error("RSC", "getRscData error", err);
@@ -1,16 +1,19 @@
1
1
  import { RscElementResolver } from './resolveRscData';
2
+ import { SpaceRevision } from '@plitzi/sdk-shared';
2
3
  /**
3
- * A server provider whose `query` is a file this server serves from `publicDir` — `/data/home.json` — read from disk
4
- * and handed to the render, so the page arrives with the section in it.
5
- *
6
- * A project with no backend keeps its data in `public/data/*.json`, and an `apiContainer` fetching it from the browser
7
- * leaves the server-rendered page without those sections: a flash of nothing, no content for a crawler, and no
8
- * anchor in place for `/#section` to land on. Read here, it is the same bytes the browser would have fetched.
9
- *
10
- * Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
11
- * in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
12
- *
13
- * Answered in the shape a browser request publishes — `{ status, data }`, the body under `data` — so a provider reads
14
- * `<source>.data.<field>` whichever runtime it has, and moving it to the server changes no binding.
4
+ * A server provider whose `query` is a file this server serves from `publicDir` — `/hours.json` — read from disk, so
5
+ * the page arrives with the section in it rather than fetching in the browser what the server already has. The same
6
+ * bytes the browser would have fetched.
15
7
  */
16
8
  export declare const publicFileResolver: (publicDir: string) => RscElementResolver;
9
+ /**
10
+ * A server provider whose `query` is `/data/<file>`, read from `dataDir` — the project's own data, which is never
11
+ * served as a file: nobody downloads the folder. What the provider reads is in the page it renders.
12
+ */
13
+ export declare const dataFileResolver: (dataDir: string) => RscElementResolver;
14
+ /**
15
+ * A server provider whose `query` is `/data/<file>`, answered from the space's own data as Plitzi keeps it — of the
16
+ * version being rendered, read off the same deployment record an action's run is (`req.ctx.spaceDeployment`), so a
17
+ * published page reads the data it was published with. What `dataFileResolver` is for a folder, for a platform.
18
+ */
19
+ export declare const dataLookupResolver: (getData: (spaceId: number, at: SpaceRevision) => Promise<Record<string, string> | undefined>) => RscElementResolver;
@@ -1,32 +1,28 @@
1
1
  import path from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { PROJECT_DATA_PREFIX } from "@plitzi/sdk-shared/server/rsc/projectData";
3
4
  //#region src/modules/rsc/publicFileResolver.ts
5
+ /** Where a provider's `query` is a path: a URL, a protocol-relative one or one with `{{tokens}}` is not. */
6
+ var pathOf = (query) => {
7
+ if (typeof query !== "string" || !query.startsWith("/") || query.startsWith("//") || query.includes("{{")) return;
8
+ try {
9
+ return decodeURIComponent(query.split(/[?#]/)[0]);
10
+ } catch {
11
+ return;
12
+ }
13
+ };
4
14
  /**
5
- * A server provider whose `query` is a file this server serves from `publicDir` — `/data/home.json` — read from disk
6
- * and handed to the render, so the page arrives with the section in it.
7
- *
8
- * A project with no backend keeps its data in `public/data/*.json`, and an `apiContainer` fetching it from the browser
9
- * leaves the server-rendered page without those sections: a flash of nothing, no content for a crawler, and no
10
- * anchor in place for `/#section` to land on. Read here, it is the same bytes the browser would have fetched.
11
- *
12
- * Only a plain path inside `publicDir`: a URL, one that climbs out of the folder, or one with `{{tokens}}` (resolved
13
- * in the browser against the visitor's route and state) is not this resolver's, and the element is left as it was.
14
- *
15
- * Answered in the shape a browser request publishes — `{ status, data }`, the body under `data` — so a provider reads
16
- * `<source>.data.<field>` whichever runtime it has, and moving it to the server changes no binding.
15
+ * A server provider whose `query` names a JSON file under `prefix`, read from the same path inside `folder` and handed
16
+ * to the render — answered in the shape a browser request publishes, `{ status, data }`, so a provider reads
17
+ * `<source>.data.<field>` whichever runtime it has. A path outside `prefix`, or one that climbs out of the folder, is
18
+ * not this resolver's, and the element is left as it was; a file not there, or not JSON, is the provider's error state.
17
19
  */
18
- var publicFileResolver = (publicDir) => {
19
- const root = path.resolve(publicDir);
20
+ var fileResolver = (folder, prefix) => {
21
+ const root = path.resolve(folder);
20
22
  return async ({ element, signal }) => {
21
- const query = element.attributes.query;
22
- if (typeof query !== "string" || !query.startsWith("/") || query.startsWith("//") || query.includes("{{")) return;
23
- let pathname;
24
- try {
25
- pathname = decodeURIComponent(query.split(/[?#]/)[0]);
26
- } catch {
27
- return;
28
- }
29
- const file = path.resolve(root, `.${pathname}`);
23
+ const pathname = pathOf(element.attributes.query);
24
+ if (pathname === void 0 || !pathname.startsWith(prefix)) return;
25
+ const file = path.resolve(root, `.${pathname.slice(prefix.length - 1)}`);
30
26
  if (!file.startsWith(`${root}${path.sep}`)) return;
31
27
  try {
32
28
  return {
@@ -41,5 +37,41 @@ var publicFileResolver = (publicDir) => {
41
37
  }
42
38
  };
43
39
  };
40
+ /**
41
+ * A server provider whose `query` is a file this server serves from `publicDir` — `/hours.json` — read from disk, so
42
+ * the page arrives with the section in it rather than fetching in the browser what the server already has. The same
43
+ * bytes the browser would have fetched.
44
+ */
45
+ var publicFileResolver = (publicDir) => fileResolver(publicDir, "/");
46
+ /**
47
+ * A server provider whose `query` is `/data/<file>`, read from `dataDir` — the project's own data, which is never
48
+ * served as a file: nobody downloads the folder. What the provider reads is in the page it renders.
49
+ */
50
+ var dataFileResolver = (dataDir) => fileResolver(dataDir, PROJECT_DATA_PREFIX);
51
+ /**
52
+ * A server provider whose `query` is `/data/<file>`, answered from the space's own data as Plitzi keeps it — of the
53
+ * version being rendered, read off the same deployment record an action's run is (`req.ctx.spaceDeployment`), so a
54
+ * published page reads the data it was published with. What `dataFileResolver` is for a folder, for a platform.
55
+ */
56
+ var dataLookupResolver = (getData) => {
57
+ return async ({ element, spaceId, environment, req }) => {
58
+ const pathname = pathOf(element.attributes.query);
59
+ if (pathname === void 0 || !pathname.startsWith(PROJECT_DATA_PREFIX)) return;
60
+ const deployment = req.ctx.spaceDeployment;
61
+ const text = (await getData(spaceId, {
62
+ environment: deployment?.environment ?? environment,
63
+ revision: deployment?.revision ?? 0
64
+ }))?.[pathname.slice(PROJECT_DATA_PREFIX.length)];
65
+ if (text === void 0) return null;
66
+ try {
67
+ return {
68
+ status: 200,
69
+ data: JSON.parse(text)
70
+ };
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+ };
44
76
  //#endregion
45
- export { publicFileResolver };
77
+ export { dataFileResolver, dataLookupResolver, publicFileResolver };
@@ -39,6 +39,8 @@ export type ResolveRscDataOptions = {
39
39
  * per deployment as `rsc.elementTimeoutMs`.
40
40
  */
41
41
  timeoutMs?: number;
42
+ /** Whoever asked stopped waiting: every element still resolving is cancelled, as when its budget runs out. */
43
+ signal?: AbortSignal;
42
44
  };
43
45
  /**
44
46
  * Resolves the server-side data slices for the page addressed by `req`.
@@ -47,4 +49,4 @@ export type ResolveRscDataOptions = {
47
49
  * and the route params are what the browser will see. Each `runtime: 'server'` element resolves independently:
48
50
  * a provider that fails or hangs costs its own slice and nothing else.
49
51
  */
50
- export declare const resolveRscData: ({ schema, req, spaceId, environment, user, ids, resolveElement, flagOverrides, timeoutMs }: ResolveRscDataOptions) => Promise<SSRRscData>;
52
+ export declare const resolveRscData: ({ schema, req, spaceId, environment, user, ids, resolveElement, flagOverrides, timeoutMs, signal }: ResolveRscDataOptions) => Promise<SSRRscData>;
@@ -1,4 +1,5 @@
1
1
  import { serverLog } from "../../helpers/serverLog.js";
2
+ import onAbort from "../../helpers/onAbort.js";
2
3
  import { matchRscPage } from "./matchRscPage.js";
3
4
  import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
4
5
  import { collectServerElements } from "@plitzi/sdk-shared/schema/serverElements";
@@ -11,18 +12,20 @@ var DEFAULT_ELEMENT_TIMEOUT_MS = 5e3;
11
12
  * the loser running to its own timeout — for actions, five more seconds of a held slot and an in-flight request
12
13
  * per element, for a page that was answered without it.
13
14
  */
14
- var withBudget = async (run, timeoutMs, label) => {
15
+ var withBudget = async (run, timeoutMs, label, asked) => {
15
16
  const controller = new AbortController();
16
17
  let timer;
18
+ const releaseAsked = asked ? onAbort(asked, () => controller.abort()) : void 0;
17
19
  try {
18
20
  return await Promise.race([run(controller.signal), new Promise((_resolve, reject) => {
19
21
  timer = setTimeout(() => {
20
22
  controller.abort();
21
- reject(/* @__PURE__ */ new Error(`RSC resolution timed out after ${timeoutMs}ms for ${label}`));
23
+ reject(/* @__PURE__ */ new Error(`RSC resolution timed out after ${timeoutMs}ms for ${label} — its section arrives empty. A slower producer is given more with \`createServer({ rsc: { elementTimeoutMs } })\`.`));
22
24
  }, timeoutMs);
23
25
  })]);
24
26
  } finally {
25
27
  if (timer) clearTimeout(timer);
28
+ releaseAsked?.();
26
29
  }
27
30
  };
28
31
  /**
@@ -32,7 +35,7 @@ var withBudget = async (run, timeoutMs, label) => {
32
35
  * and the route params are what the browser will see. Each `runtime: 'server'` element resolves independently:
33
36
  * a provider that fails or hangs costs its own slice and nothing else.
34
37
  */
35
- var resolveRscData = async ({ schema, req, spaceId, environment, user, ids, resolveElement, flagOverrides, timeoutMs = DEFAULT_ELEMENT_TIMEOUT_MS }) => {
38
+ var resolveRscData = async ({ schema, req, spaceId, environment, user, ids, resolveElement, flagOverrides, timeoutMs = DEFAULT_ELEMENT_TIMEOUT_MS, signal }) => {
36
39
  if (schema.rsc?.enabled === false) return {};
37
40
  const match = matchRscPage(schema, req.path, user);
38
41
  if (!match) return { serverData: {} };
@@ -58,10 +61,10 @@ var resolveRscData = async ({ schema, req, spaceId, environment, user, ids, reso
58
61
  environment,
59
62
  user,
60
63
  signal
61
- }), timeoutMs, `element ${element.id}`)
64
+ }), timeoutMs, `element ${element.id}`, signal)
62
65
  })))).reduce((acum, result, index) => {
63
66
  if (result.status === "rejected") {
64
- serverLog.error("RSC", `element ${targets[index].id} failed to resolve`, result.reason);
67
+ if (!signal?.aborted) serverLog.error("RSC", `element ${targets[index].id} failed to resolve`, result.reason);
65
68
  return acum;
66
69
  }
67
70
  if (result.value.data !== void 0) acum[result.value.id] = result.value.data;
@@ -17,6 +17,11 @@ export declare const inspectRuntime: (bytes: Uint8Array) => {
17
17
  entry: string;
18
18
  files: string[];
19
19
  };
20
+ /**
21
+ * A project's runtime module, as a server of its own runs it — `src/runtime/index.ts`, or what `build` compiled it to —
22
+ * or nothing when the project has none: a project starts one by writing it (`plitzi add runtime`), with nothing to wire.
23
+ */
24
+ export declare const loadRuntimeModule: (file: string) => Promise<SpaceRuntime | undefined>;
20
25
  /**
21
26
  * A packed runtime written into `dir` and imported: its module's default export. `dir` must be inside the host's own
22
27
  * project, so the packages the bundle leaves to the host resolve to the host's — one copy of each.
@@ -85,6 +85,24 @@ var inspectRuntime = (bytes) => {
85
85
  };
86
86
  };
87
87
  var isRuntime = (value) => isRecord(value) && typeof value.start === "function";
88
+ /** A module's default export, held to what a runtime is — or refused, saying how to write one. */
89
+ var runtimeOf = (loaded) => {
90
+ const runtime = isRecord(loaded) ? loaded.default : void 0;
91
+ if (!isRuntime(runtime)) throw new Error("A runtime module exports its runtime by default: export default defineRuntime({ start: … })");
92
+ return runtime;
93
+ };
94
+ /**
95
+ * A project's runtime module, as a server of its own runs it — `src/runtime/index.ts`, or what `build` compiled it to —
96
+ * or nothing when the project has none: a project starts one by writing it (`plitzi add runtime`), with nothing to wire.
97
+ */
98
+ var loadRuntimeModule = async (file) => {
99
+ try {
100
+ await fs.access(file);
101
+ } catch {
102
+ return;
103
+ }
104
+ return runtimeOf(await import(pathToFileURL(file).href));
105
+ };
88
106
  /**
89
107
  * A packed runtime written into `dir` and imported: its module's default export. `dir` must be inside the host's own
90
108
  * project, so the packages the bundle leaves to the host resolve to the host's — one copy of each.
@@ -93,10 +111,7 @@ var loadRuntime = async (bytes, dir) => {
93
111
  const { entry, files } = unpack(bytes);
94
112
  await fs.mkdir(dir, { recursive: true });
95
113
  await Promise.all(Object.entries(files).map(([name, content]) => fs.writeFile(path.join(dir, name), Buffer.from(content, "base64"))));
96
- const loaded = await import(pathToFileURL(path.join(dir, entry)).href);
97
- const runtime = isRecord(loaded) ? loaded.default : void 0;
98
- if (!isRuntime(runtime)) throw new Error("A runtime module exports its runtime by default: export default defineRuntime({ start: … })");
99
- return runtime;
114
+ return runtimeOf(await import(pathToFileURL(path.join(dir, entry)).href));
100
115
  };
101
116
  //#endregion
102
- export { MAX_RUNTIME_BUNDLE_BYTES, inspectRuntime, loadRuntime, packRuntime, runtimeBundleId };
117
+ export { MAX_RUNTIME_BUNDLE_BYTES, inspectRuntime, loadRuntime, loadRuntimeModule, packRuntime, runtimeBundleId };
@@ -0,0 +1,34 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { ActionKvAdapter } from '../actions/types';
3
+ export type SqliteKvOptions = ({
4
+ /** The database file — created, with its folder, when it is not there. Opened in WAL mode. */
5
+ file: string;
6
+ db?: never;
7
+ } | {
8
+ /** A database the deployment opened and shares with its own tables (a job queue, an activity log). */
9
+ db: DatabaseSync;
10
+ file?: never;
11
+ }) & {
12
+ /** The table the keys live in, created when it is not there. Default `action_kv`. */
13
+ table?: string;
14
+ };
15
+ /**
16
+ * A key/value store in a SQLite file, over Node's own `node:sqlite` — nothing to install, nothing to run beside the
17
+ * server. For a deployment with no database that wants what `kv` keeps to survive a restart AND to be shared: every
18
+ * process pointed at the same file — replicas on one machine, a server started with `workers` — sees the same keys,
19
+ * the same counters and the same single-flight keys.
20
+ *
21
+ * ```ts
22
+ * import { createSqliteKv } from '@plitzi/sdk-server/sqlite';
23
+ *
24
+ * createServer({ action: { kv: createSqliteKv({ file: 'data/kv.sqlite' }) } });
25
+ * ```
26
+ *
27
+ * Every operation is ONE statement, so it is atomic without a transaction: an increment is an upsert that adds in
28
+ * place, a swap is an update whose `WHERE` names the value it expects. Lifetimes are instants on the store's clock,
29
+ * enforced on every read, and expired rows are swept as writes go by.
30
+ *
31
+ * Needs a Node with `node:sqlite` unflagged (22.13 and later), which still prints an `ExperimentalWarning` once when
32
+ * it loads — the reason this is its own entry rather than the default.
33
+ */
34
+ export declare const createSqliteKv: (options: SqliteKvOptions) => ActionKvAdapter;
@@ -0,0 +1,99 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ //#region src/modules/sqlite/kv.ts
5
+ /**
6
+ * The STORE's clock in epoch ms, read inside each statement: every process on the file agrees on what "now" is.
7
+ * `julianday()` with no argument is the current instant.
8
+ */
9
+ var NOW_MS = "CAST((julianday() - 2440587.5) * 86400000 AS INTEGER)";
10
+ var open = (file) => {
11
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
12
+ const db = new DatabaseSync(file);
13
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA synchronous = NORMAL;");
14
+ return db;
15
+ };
16
+ /**
17
+ * A key/value store in a SQLite file, over Node's own `node:sqlite` — nothing to install, nothing to run beside the
18
+ * server. For a deployment with no database that wants what `kv` keeps to survive a restart AND to be shared: every
19
+ * process pointed at the same file — replicas on one machine, a server started with `workers` — sees the same keys,
20
+ * the same counters and the same single-flight keys.
21
+ *
22
+ * ```ts
23
+ * import { createSqliteKv } from '@plitzi/sdk-server/sqlite';
24
+ *
25
+ * createServer({ action: { kv: createSqliteKv({ file: 'data/kv.sqlite' }) } });
26
+ * ```
27
+ *
28
+ * Every operation is ONE statement, so it is atomic without a transaction: an increment is an upsert that adds in
29
+ * place, a swap is an update whose `WHERE` names the value it expects. Lifetimes are instants on the store's clock,
30
+ * enforced on every read, and expired rows are swept as writes go by.
31
+ *
32
+ * Needs a Node with `node:sqlite` unflagged (22.13 and later), which still prints an `ExperimentalWarning` once when
33
+ * it loads — the reason this is its own entry rather than the default.
34
+ */
35
+ var createSqliteKv = (options) => {
36
+ const table = options.table ?? "action_kv";
37
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`createSqliteKv: "${table}" is not a table name (letters, digits and _, not starting with a digit).`);
38
+ const db = options.db ?? open(options.file);
39
+ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)`);
40
+ const live = `(expires_at IS NULL OR expires_at > ${NOW_MS})`;
41
+ const expiry = (ttlSeconds) => ttlSeconds === void 0 ? null : ttlSeconds * 1e3;
42
+ const expiresAt = `CASE WHEN ? IS NULL THEN NULL ELSE ${NOW_MS} + ? END`;
43
+ const statements = {
44
+ get: db.prepare(`SELECT value FROM ${table} WHERE key = ? AND ${live}`),
45
+ set: db.prepare(`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ${expiresAt})
46
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`),
47
+ delete: db.prepare(`DELETE FROM ${table} WHERE key = ?`),
48
+ increment: db.prepare(`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, NULL)
49
+ ON CONFLICT (key) DO UPDATE SET
50
+ value = CASE WHEN ${table}.expires_at IS NOT NULL AND ${table}.expires_at <= ${NOW_MS}
51
+ THEN excluded.value ELSE CAST(${table}.value + ? AS TEXT) END,
52
+ expires_at = CASE WHEN ${table}.expires_at IS NOT NULL AND ${table}.expires_at <= ${NOW_MS}
53
+ THEN NULL ELSE ${table}.expires_at END
54
+ RETURNING value`),
55
+ expire: db.prepare(`UPDATE ${table} SET expires_at = ${expiresAt} WHERE key = ? AND ${live}`),
56
+ claim: db.prepare(`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ${expiresAt})
57
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at
58
+ WHERE ${table}.expires_at IS NOT NULL AND ${table}.expires_at <= ${NOW_MS}`),
59
+ replace: db.prepare(`UPDATE ${table} SET value = ?, expires_at = ${expiresAt} WHERE key = ? AND value = ? AND ${live}`),
60
+ sweep: db.prepare(`DELETE FROM ${table} WHERE expires_at IS NOT NULL AND expires_at <= ${NOW_MS}`)
61
+ };
62
+ /** Used-once keys (a delivery's idempotency key, a caller's window) expire and stay until swept: once a minute. */
63
+ let sweptAt = 0;
64
+ const sweep = () => {
65
+ if (Date.now() - sweptAt < 6e4) return;
66
+ sweptAt = Date.now();
67
+ statements.sweep.run();
68
+ };
69
+ const text = (row) => typeof row?.value === "string" ? row.value : void 0;
70
+ return {
71
+ get: (key) => Promise.resolve(text(statements.get.get(key))),
72
+ set: (key, value, ttlSeconds) => {
73
+ statements.set.run(key, value, expiry(ttlSeconds), expiry(ttlSeconds));
74
+ sweep();
75
+ return Promise.resolve();
76
+ },
77
+ delete: (key) => {
78
+ statements.delete.run(key);
79
+ return Promise.resolve();
80
+ },
81
+ increment: (key, amount) => {
82
+ const step = Number.isInteger(amount) ? BigInt(amount) : amount;
83
+ const value = text(statements.increment.get(key, String(amount), step));
84
+ sweep();
85
+ return Promise.resolve(Number(value ?? amount));
86
+ },
87
+ expire: (key, ttlSeconds) => {
88
+ statements.expire.run(expiry(ttlSeconds), expiry(ttlSeconds), key);
89
+ return Promise.resolve();
90
+ },
91
+ swap: (key, expected, next, ttlSeconds) => {
92
+ const written = expected === void 0 ? statements.claim.run(key, next, expiry(ttlSeconds), expiry(ttlSeconds)) : statements.replace.run(next, expiry(ttlSeconds), expiry(ttlSeconds), key, expected);
93
+ sweep();
94
+ return Promise.resolve(written.changes === 1);
95
+ }
96
+ };
97
+ };
98
+ //#endregion
99
+ export { createSqliteKv };
@@ -0,0 +1 @@
1
+ export {};
@@ -9,13 +9,14 @@ import { loadPluginComponents } from "./loadPluginComponents.js";
9
9
  import "../../core/previewToken.js";
10
10
  import { resolveActionEndpoint, resolveRscEndpoint } from "../../core/services/resolve.js";
11
11
  import { createOfflineDataLoader } from "../../helpers/offlineDataLoader.js";
12
+ import { hydrationPayload, spaceDocument } from "../../helpers/hydrationPayload.js";
13
+ import { publishSpaceDocument } from "./spaceDocument.js";
12
14
  import { registerExternalPlugins } from "./registerExternalPlugins.js";
13
15
  import { reportMissingPlugins } from "./reportMissingPlugins.js";
14
16
  import { resolvePageSeo } from "./resolvePageSeo.js";
15
17
  import { imagesPathOf } from "../../core/http/stages/images.js";
16
18
  import { sdkAssetVersion } from "../../core/sdkAssets.js";
17
19
  import { buildServerInfo } from "../../helpers/buildServerInfo.js";
18
- import { hydrationPayload } from "../../helpers/hydrationPayload.js";
19
20
  import { resolveDebugMode } from "../../helpers/resolveDebugMode.js";
20
21
  import { flagUserFromSSR, flagValues, resolveFlags } from "@plitzi/sdk-shared/flags";
21
22
  import { hasServerElements } from "@plitzi/sdk-shared/schema/serverElements";
@@ -23,6 +24,8 @@ import { debugCookieName } from "@plitzi/sdk-shared/devTools";
23
24
  import { themeFromCookies } from "@plitzi/sdk-shared/theme";
24
25
  import { paintedKeys, paintedStateFor } from "@plitzi/sdk-shared/state/paintedState";
25
26
  import { fontUrlResolver, fontsToHead } from "@plitzi/sdk-shared/style";
27
+ import { pluginDeclarationOf, pluginTypesOf } from "@plitzi/sdk-shared/plugins/declaration";
28
+ import { pageElementTypes } from "@plitzi/sdk-shared/schema/pageElements";
26
29
  //#region src/modules/ssr/prepareRender.tsx
27
30
  /** Last resort only: used for a page that declares no SEO title and a deployment that supplies none either. */
28
31
  var DEFAULT_TITLE = "Plitzi App";
@@ -36,6 +39,14 @@ var declaredTheme = (style) => {
36
39
  const declared = style?.theme?.default;
37
40
  return declared && declared !== "system" ? declared : void 0;
38
41
  };
42
+ var declarations = /* @__PURE__ */ new WeakMap();
43
+ var declarationOf = (component) => {
44
+ const known = declarations.get(component);
45
+ if (known) return known;
46
+ const declaration = pluginDeclarationOf(component);
47
+ declarations.set(component, declaration);
48
+ return declaration;
49
+ };
39
50
  var prepareRender = async (req, config, spaceId, environment, revision, pluginManager, offlineDataCache, metrics, offlineDataOverride) => {
40
51
  const m = (name, fn) => metrics ? metrics.measure(name, fn) : Promise.resolve(fn());
41
52
  const offlineCacheKey = environment !== "main" ? buildOfflineDataCacheKey(spaceId, environment, revision, req.ctx.spaceDeployment?.flagsVersion) : void 0;
@@ -147,6 +158,13 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
147
158
  * page, so it hydrates with the flags it was drawn with.
148
159
  */
149
160
  const { server: serverFlags, qa: forcedFlags } = flagOverrides;
161
+ /**
162
+ * The space travels beside the page rather than in it, fetched while the scripts are — see `spaceDocument`.
163
+ *
164
+ * Not for a draft, which no other process could produce when the fetch reaches it, and not for a deployment's own
165
+ * template, which was written for the space inline; a page with no script needs no space at all.
166
+ */
167
+ const spaceDocumentPath = offlineData !== void 0 && offlineDataOverride === void 0 && config.templateFn === void 0 && config.ssrOnly !== true ? publishSpaceDocument(spaceId, environment, spaceDocument(offlineData)) : void 0;
150
168
  const offlineDataStr = hydrationPayload(offlineData, {
151
169
  offlineMode: true,
152
170
  environment,
@@ -160,7 +178,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
160
178
  ...actionRuns ? { actionRuns } : {},
161
179
  ...serverFlags ? { serverFlags } : {},
162
180
  ...forcedFlags ? { forcedFlags } : {}
163
- });
181
+ }, { apart: spaceDocumentPath !== void 0 });
164
182
  const pluginNames = req.ctx.spaceDeployment?.pluginNames ?? [];
165
183
  const pluginSources = req.ctx.spaceDeployment?.pluginSources;
166
184
  const pluginBaseNames = new Set(pluginNames.map((n) => n.replace(/@[^@]*$/, "")));
@@ -178,10 +196,29 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
178
196
  const entries = allPluginNames.length > 0 ? await pluginManager.getEntries(allPluginNames) : [];
179
197
  const pluginComponents = await m("plugins", () => loadPluginComponents(entries, pluginManager.getComponents()));
180
198
  reportMissingPlugins(spaceId, schema, /* @__PURE__ */ new Set([...Object.keys(pluginComponents), ...entries.map((entry) => entry.keyName)]));
181
- const templatePlugins = (entries.length > 0 ? entries : req.ctx.spaceDeployment?.templateProps?.plugins)?.map((entry) => ({
182
- ...entry,
183
- ssr: entry.keyName in pluginComponents
184
- }));
199
+ const templateEntries = entries.length > 0 ? entries : req.ctx.spaceDeployment?.templateProps?.plugins;
200
+ /**
201
+ * The plugins this page draws none of: the browser is sent what they declare, and loads one when a page that draws
202
+ * it is opened. Their code is what the first paint waited on — a page of the website downloaded the builder's.
203
+ *
204
+ * Only one the server holds the component of can wait, since its declaration is read from it; a page matched to
205
+ * nothing, which cannot say what it draws, waits for none.
206
+ */
207
+ const pageTypes = schema !== void 0 && pageMatch !== void 0 ? pageElementTypes(schema, pageMatch.pageId) : void 0;
208
+ const deferredOf = (entry) => {
209
+ const plugin = pageTypes && entry.js ? pluginComponents[entry.keyName] : void 0;
210
+ if (!plugin) return;
211
+ const declaration = declarationOf(plugin.component);
212
+ return pluginTypesOf(entry.keyName, declaration).some((type) => pageTypes?.has(type)) ? void 0 : declaration;
213
+ };
214
+ const templatePlugins = templateEntries?.map((entry) => {
215
+ const deferred = deferredOf(entry);
216
+ return {
217
+ ...entry,
218
+ ssr: entry.keyName in pluginComponents,
219
+ ...deferred ? { deferred } : {}
220
+ };
221
+ });
185
222
  const vendorJs = (debugAuthorized ? "/sdk-assets/plitzi-sdk-dev-vendor.js" : "/sdk-assets/plitzi-sdk-vendor.js") + v;
186
223
  return {
187
224
  componentProps: {
@@ -202,6 +239,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
202
239
  title: DEFAULT_TITLE,
203
240
  jsPath: `/sdk-assets/plitzi-sdk.js${v}`,
204
241
  cssPath: `/sdk-assets/plitzi-sdk.css${v}`,
242
+ iconsCssPath: `/sdk-assets/plitzi-sdk-icons.css${v}`,
243
+ spaceDocumentPath,
205
244
  react: vendorJs,
206
245
  reactJsx: vendorJs,
207
246
  reactDom: vendorJs,
@@ -0,0 +1,25 @@
1
+ import { TtlCache } from '../../helpers/cache';
2
+ import { SpaceDocument } from '../../helpers/hydrationPayload';
3
+ import { Environment, SSRPageServerConfig, SSRRequest, SSRResponseHelpers } from '@plitzi/sdk-shared';
4
+ /**
5
+ * The whole space as a resource of its own, beside the page rather than inside it.
6
+ *
7
+ * Embedded, it was most of every document — megabytes of a big space, before the first word of the page — and it was
8
+ * downloaded again on every page and every visit. Apart, the page arrives at the size of what it shows, and the space
9
+ * is fetched while the scripts are, once: its address is its content's hash, so it is kept until it changes.
10
+ *
11
+ * It is still the whole space: the server draws the first page, and every page after it is the browser's, with no
12
+ * server to ask.
13
+ */
14
+ export declare const SPACE_DOCUMENT_PATH = "/_plitzi/space/";
15
+ /** Where the page fetches `document` from — and the promise that this process can answer it. */
16
+ export declare const publishSpaceDocument: (spaceId: number, environment: Environment, document: SpaceDocument) => string;
17
+ /**
18
+ * Answers `/_plitzi/space/<hash>.json` for the space this host serves, behind the same gates as its pages.
19
+ *
20
+ * Every page of a space already carried all of it, so whoever may open one page may read this. A hash this process
21
+ * did not just publish is looked up from the space as it is now: the same text when another process published it,
22
+ * or the space as it changed since — answered all the same, since it is what the page will find on its next load,
23
+ * but not to be kept under a name that is not its own.
24
+ */
25
+ export declare const handleSpaceDocument: (req: SSRRequest, res: SSRResponseHelpers, config: SSRPageServerConfig, offlineDataCache?: TtlCache<string>) => Promise<void>;