@plitzi/sdk-server 0.38.4 → 0.38.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +350 -0
  2. package/README.md +126 -17
  3. package/dist/actions.d.ts +8 -5
  4. package/dist/actions.js +3 -1
  5. package/dist/adapters/jsonAdapters.d.ts +1 -1
  6. package/dist/adapters/jsonAdapters.js +16 -3
  7. package/dist/core/configSeam.js +20 -4
  8. package/dist/core/createServer.js +3 -2
  9. package/dist/core/handlers/socialRouteHandlers.js +6 -1
  10. package/dist/core/http/stages/devReload.d.ts +11 -0
  11. package/dist/core/http/stages/devReload.js +17 -0
  12. package/dist/core/server/baseServer.d.ts +2 -0
  13. package/dist/core/server/baseServer.js +3 -0
  14. package/dist/core/server/pageServer.js +18 -0
  15. package/dist/core/services/registry.js +2 -0
  16. package/dist/core/services/rsc.js +1 -1
  17. package/dist/core/services/spaceDocument.d.ts +2 -0
  18. package/dist/core/services/spaceDocument.js +11 -0
  19. package/dist/functions-api.d.ts +6 -1
  20. package/dist/functionsRunner.d.ts +5 -0
  21. package/dist/functionsRunner.js +2 -1
  22. package/dist/helpers/hydrationPayload.d.ts +25 -4
  23. package/dist/helpers/hydrationPayload.js +36 -30
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.js +2 -2
  26. package/dist/modules/actions/index.d.ts +11 -2
  27. package/dist/modules/actions/index.js +29 -7
  28. package/dist/modules/actions/runtime/fileKv.d.ts +28 -0
  29. package/dist/modules/actions/runtime/fileKv.js +95 -0
  30. package/dist/modules/actions/runtime/fileKv.test.d.ts +1 -0
  31. package/dist/modules/actions/runtime/memoryKv.d.ts +23 -0
  32. package/dist/modules/actions/runtime/memoryKv.js +39 -24
  33. package/dist/modules/actions/runtime/namespaceKv.d.ts +2 -0
  34. package/dist/modules/actions/runtime/namespaceKv.js +5 -3
  35. package/dist/modules/actions/runtime/renderResolver.d.ts +3 -2
  36. package/dist/modules/actions/runtime/renderResolver.js +13 -9
  37. package/dist/modules/actions/runtime/renderShare.d.ts +7 -24
  38. package/dist/modules/actions/runtime/renderShare.js +47 -13
  39. package/dist/modules/actions/runtime/runAction.js +15 -1
  40. package/dist/modules/actions/runtime/scope.js +16 -1
  41. package/dist/modules/actions/runtime/signing.js +4 -1
  42. package/dist/modules/actions/runtime/taskContext.d.ts +7 -1
  43. package/dist/modules/actions/runtime/taskContext.js +6 -2
  44. package/dist/modules/actions/tasks/registry.d.ts +3 -1
  45. package/dist/modules/actions/tasks/registry.js +15 -1
  46. package/dist/modules/actions/types.d.ts +15 -2
  47. package/dist/modules/functions/config.d.ts +6 -0
  48. package/dist/modules/functions/context.d.ts +4 -2
  49. package/dist/modules/functions/context.js +43 -21
  50. package/dist/modules/functions/load.d.ts +8 -1
  51. package/dist/modules/functions/load.js +8 -4
  52. package/dist/modules/functions/manifest.d.ts +4 -1
  53. package/dist/modules/functions/manifest.js +14 -2
  54. package/dist/modules/functions/native.d.ts +2 -1
  55. package/dist/modules/functions/native.js +2 -2
  56. package/dist/modules/functions/plugins.test.d.ts +1 -0
  57. package/dist/modules/functions/routes.d.ts +8 -3
  58. package/dist/modules/functions/routes.js +41 -21
  59. package/dist/modules/functions/scope.d.ts +30 -0
  60. package/dist/modules/functions/scope.js +28 -0
  61. package/dist/modules/functions/space.d.ts +10 -5
  62. package/dist/modules/functions/space.js +37 -15
  63. package/dist/modules/rsc/connectorRscData.d.ts +3 -1
  64. package/dist/modules/rsc/connectorRscData.js +8 -4
  65. package/dist/modules/rsc/handler.d.ts +3 -16
  66. package/dist/modules/rsc/handler.js +10 -4
  67. package/dist/modules/rsc/publicFileResolver.d.ts +15 -12
  68. package/dist/modules/rsc/publicFileResolver.js +56 -24
  69. package/dist/modules/rsc/resolveRscData.d.ts +3 -1
  70. package/dist/modules/rsc/resolveRscData.js +8 -5
  71. package/dist/modules/runtime/bundle.d.ts +5 -0
  72. package/dist/modules/runtime/bundle.js +20 -5
  73. package/dist/modules/sqlite/kv.d.ts +34 -0
  74. package/dist/modules/sqlite/kv.js +99 -0
  75. package/dist/modules/sqlite/kv.test.d.ts +1 -0
  76. package/dist/modules/ssr/Component.d.ts +2 -1
  77. package/dist/modules/ssr/Component.js +2 -1
  78. package/dist/modules/ssr/prepareRender.js +48 -6
  79. package/dist/modules/ssr/spaceDocument.d.ts +25 -0
  80. package/dist/modules/ssr/spaceDocument.js +72 -0
  81. package/dist/modules/ssr/spaceDocument.test.d.ts +1 -0
  82. package/dist/modules/ssr/testing/offlineData.d.ts +5 -0
  83. package/dist/modules/ssr/views/template.ejs +122 -20
  84. package/dist/plugins/compile.js +6 -21
  85. package/dist/plugins/compile.test.d.ts +1 -0
  86. package/dist/plugins/copy.d.ts +5 -0
  87. package/dist/plugins/copy.js +16 -1
  88. package/dist/plugins/copy.test.d.ts +1 -0
  89. package/dist/plugins/manager.d.ts +13 -0
  90. package/dist/plugins/manager.js +40 -11
  91. package/dist/plugins/watch.d.ts +12 -0
  92. package/dist/plugins/watch.js +79 -0
  93. package/dist/runtime.d.ts +1 -1
  94. package/dist/runtime.js +2 -2
  95. package/dist/sqlite.d.ts +17 -0
  96. package/dist/sqlite.js +2 -0
  97. package/package.json +8 -3
  98. package/skills/plitzi-authoring/CHEATSHEET.md +4 -2
  99. package/skills/plitzi-authoring/SKILL.md +20 -19
  100. package/skills/plitzi-authoring/recipes/plugin.ts +1 -1
  101. package/skills/plitzi-authoring/recipes/server-data.ts +3 -3
  102. package/skills/plitzi-authoring/recipes/show-data.ts +3 -2
  103. package/skills/plitzi-authoring/recipes/typed-data.ts +1 -1
  104. package/skills/plitzi-authoring/reference/authoring-errors.md +8 -2
  105. package/skills/plitzi-authoring/reference/colours-and-motion.md +3 -1
  106. package/skills/plitzi-authoring/reference/data-and-visibility.md +24 -22
  107. package/skills/plitzi-authoring/reference/drawing.md +24 -0
  108. package/skills/plitzi-authoring/reference/efficiency.md +19 -2
  109. package/skills/plitzi-authoring/reference/flows.md +3 -0
  110. package/skills/plitzi-authoring/reference/layouts.md +8 -23
  111. package/skills/plitzi-authoring/reference/performance.md +2 -2
  112. package/skills/plitzi-authoring/reference/plugins.md +51 -38
  113. package/skills/plitzi-authoring/reference/review-checklist.md +2 -0
  114. package/skills/plitzi-authoring/reference/structure.md +32 -1
  115. package/skills/plitzi-authoring/reference/templates.md +5 -2
  116. package/skills/plitzi-authoring/reference/testing.md +9 -2
  117. package/skills/plitzi-authoring/reference/typed-sources.md +3 -1
  118. package/skills/plitzi-authoring/reference/validation.md +1 -1
@@ -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;
@@ -84,6 +95,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
84
95
  const version = config.assetVersion ?? sdkAssetVersion();
85
96
  const v = version ? `?v=${version}` : "";
86
97
  const sdkDevToolsStylePath = `/sdk-assets/plitzi-sdk-devtools.css${v}`;
98
+ const sdkIconsStylePath = `/sdk-assets/plitzi-sdk-icons.css${v}`;
87
99
  /**
88
100
  * Two facts, and they have to leave this server separately.
89
101
  *
@@ -147,12 +159,20 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
147
159
  * page, so it hydrates with the flags it was drawn with.
148
160
  */
149
161
  const { server: serverFlags, qa: forcedFlags } = flagOverrides;
162
+ /**
163
+ * The space travels beside the page rather than in it, fetched while the scripts are — see `spaceDocument`.
164
+ *
165
+ * Not for a draft, which no other process could produce when the fetch reaches it, and not for a deployment's own
166
+ * template, which was written for the space inline; a page with no script needs no space at all.
167
+ */
168
+ const spaceDocumentPath = offlineData !== void 0 && offlineDataOverride === void 0 && config.templateFn === void 0 && config.ssrOnly !== true ? publishSpaceDocument(spaceId, environment, spaceDocument(offlineData)) : void 0;
150
169
  const offlineDataStr = hydrationPayload(offlineData, {
151
170
  offlineMode: true,
152
171
  environment,
153
172
  renderMode: "raw",
154
173
  server,
155
174
  sdkDevToolsStylePath,
175
+ sdkIconsStylePath,
156
176
  ...theme ? { theme } : {},
157
177
  ...paintedState ? { state: paintedState } : {},
158
178
  ...clientAnalytics ? { analytics: clientAnalytics } : {},
@@ -160,7 +180,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
160
180
  ...actionRuns ? { actionRuns } : {},
161
181
  ...serverFlags ? { serverFlags } : {},
162
182
  ...forcedFlags ? { forcedFlags } : {}
163
- });
183
+ }, { apart: spaceDocumentPath !== void 0 });
164
184
  const pluginNames = req.ctx.spaceDeployment?.pluginNames ?? [];
165
185
  const pluginSources = req.ctx.spaceDeployment?.pluginSources;
166
186
  const pluginBaseNames = new Set(pluginNames.map((n) => n.replace(/@[^@]*$/, "")));
@@ -178,10 +198,29 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
178
198
  const entries = allPluginNames.length > 0 ? await pluginManager.getEntries(allPluginNames) : [];
179
199
  const pluginComponents = await m("plugins", () => loadPluginComponents(entries, pluginManager.getComponents()));
180
200
  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
- }));
201
+ const templateEntries = entries.length > 0 ? entries : req.ctx.spaceDeployment?.templateProps?.plugins;
202
+ /**
203
+ * The plugins this page draws none of: the browser is sent what they declare, and loads one when a page that draws
204
+ * it is opened. Their code is what the first paint waited on — a page of the website downloaded the builder's.
205
+ *
206
+ * Only one the server holds the component of can wait, since its declaration is read from it; a page matched to
207
+ * nothing, which cannot say what it draws, waits for none.
208
+ */
209
+ const pageTypes = schema !== void 0 && pageMatch !== void 0 ? pageElementTypes(schema, pageMatch.pageId) : void 0;
210
+ const deferredOf = (entry) => {
211
+ const plugin = pageTypes && entry.js ? pluginComponents[entry.keyName] : void 0;
212
+ if (!plugin) return;
213
+ const declaration = declarationOf(plugin.component);
214
+ return pluginTypesOf(entry.keyName, declaration).some((type) => pageTypes?.has(type)) ? void 0 : declaration;
215
+ };
216
+ const templatePlugins = templateEntries?.map((entry) => {
217
+ const deferred = deferredOf(entry);
218
+ return {
219
+ ...entry,
220
+ ssr: entry.keyName in pluginComponents,
221
+ ...deferred ? { deferred } : {}
222
+ };
223
+ });
185
224
  const vendorJs = (debugAuthorized ? "/sdk-assets/plitzi-sdk-dev-vendor.js" : "/sdk-assets/plitzi-sdk-vendor.js") + v;
186
225
  return {
187
226
  componentProps: {
@@ -191,6 +230,7 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
191
230
  environment: req.ctx.spaceDeployment?.environment ?? environment,
192
231
  debugMode: debugRendered,
193
232
  sdkDevToolsStylePath,
233
+ sdkIconsStylePath,
194
234
  overQuota,
195
235
  theme,
196
236
  ...paintedState ? { state: paintedState } : {},
@@ -202,6 +242,8 @@ var prepareRender = async (req, config, spaceId, environment, revision, pluginMa
202
242
  title: DEFAULT_TITLE,
203
243
  jsPath: `/sdk-assets/plitzi-sdk.js${v}`,
204
244
  cssPath: `/sdk-assets/plitzi-sdk.css${v}`,
245
+ iconsCssPath: sdkIconsStylePath,
246
+ spaceDocumentPath,
205
247
  react: vendorJs,
206
248
  reactJsx: vendorJs,
207
249
  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>;
@@ -0,0 +1,72 @@
1
+ import { buildOfflineDataCacheKey } from "../../helpers/cache/keys.js";
2
+ import { spaceDocument } from "../../helpers/hydrationPayload.js";
3
+ //#region src/modules/ssr/spaceDocument.ts
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
+ var SPACE_DOCUMENT_PATH = "/_plitzi/space/";
15
+ var DOCUMENT_PATH = /^\/_plitzi\/space\/([\w-]{1,64})\.json$/;
16
+ /**
17
+ * The documents this process just put in a page, so the request that follows is answered without reading the space
18
+ * again. Bounded: a document is megabytes, and only the latest of each space is ever asked for twice.
19
+ */
20
+ var RECENT = 8;
21
+ var recent = /* @__PURE__ */ new Map();
22
+ var recentKey = (spaceId, environment, hash) => `${String(spaceId)}|${environment}|${hash}`;
23
+ /** Where the page fetches `document` from — and the promise that this process can answer it. */
24
+ var publishSpaceDocument = (spaceId, environment, document) => {
25
+ const key = recentKey(spaceId, environment, document.hash);
26
+ recent.delete(key);
27
+ recent.set(key, document.json);
28
+ for (const oldest of recent.keys()) {
29
+ if (recent.size <= RECENT) break;
30
+ recent.delete(oldest);
31
+ }
32
+ return `${SPACE_DOCUMENT_PATH}${document.hash}.json`;
33
+ };
34
+ /**
35
+ * Answers `/_plitzi/space/<hash>.json` for the space this host serves, behind the same gates as its pages.
36
+ *
37
+ * Every page of a space already carried all of it, so whoever may open one page may read this. A hash this process
38
+ * did not just publish is looked up from the space as it is now: the same text when another process published it,
39
+ * or the space as it changed since — answered all the same, since it is what the page will find on its next load,
40
+ * but not to be kept under a name that is not its own.
41
+ */
42
+ var handleSpaceDocument = async (req, res, config, offlineDataCache) => {
43
+ const hash = DOCUMENT_PATH.exec(req.path)?.[1];
44
+ const { environment = "main", revision = 0, flagsVersion } = req.ctx.spaceDeployment ?? {};
45
+ const spaceId = req.ctx.spaceDeployment?.spaceId ?? 1;
46
+ if (!hash) {
47
+ res.setStatus(404);
48
+ res.end();
49
+ return;
50
+ }
51
+ let json = recent.get(recentKey(spaceId, environment, hash));
52
+ let current = json !== void 0;
53
+ if (json === void 0) {
54
+ const cacheKey = environment !== "main" ? buildOfflineDataCacheKey(spaceId, environment, revision, flagsVersion) : void 0;
55
+ const cached = cacheKey ? offlineDataCache?.get(cacheKey) : void 0;
56
+ const offlineData = cached ? JSON.parse(cached) : await config.adapters.getOfflineData(spaceId, environment, revision);
57
+ if (!offlineData) {
58
+ res.setStatus(404);
59
+ res.end();
60
+ return;
61
+ }
62
+ const document = spaceDocument(offlineData);
63
+ publishSpaceDocument(spaceId, environment, document);
64
+ json = document.json;
65
+ current = document.hash === hash;
66
+ }
67
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
68
+ res.setHeader("Cache-Control", current ? `${req.ctx.user ? "private" : "public"}, max-age=31536000, immutable` : "no-store");
69
+ res.send(json);
70
+ };
71
+ //#endregion
72
+ export { SPACE_DOCUMENT_PATH, handleSpaceDocument, publishSpaceDocument };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import { OfflineDataRaw, Schema } from '@plitzi/sdk-shared';
2
+ /** A space of one empty page at `/` — what a test's page server renders when the page is not what it tests. */
3
+ export declare const oneEmptyPage: Schema;
4
+ /** The documents a page server is started with: `schema`, unstyled. */
5
+ export declare const offlineDataOf: (schema?: Schema) => OfflineDataRaw;
@@ -34,10 +34,10 @@
34
34
 
35
35
  <%
36
36
  /*
37
- Two origins the document reaches for whatever the space declares: the icon font below, and the favicon.
38
- Named here so the space's own fonts can skip whichever of them it would otherwise ask for twice.
37
+ The one origin the document reaches for whatever the space declares: the favicon's. Named here so the space's
38
+ own fonts can skip it rather than ask for it twice. A font host is preconnected only when the space uses one.
39
39
  */
40
- const staticOrigins = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://cdn.plitzi.com'];
40
+ const staticOrigins = ['https://cdn.plitzi.com'];
41
41
  %>
42
42
  <% for (const origin of staticOrigins) { %>
43
43
  <link rel="preconnect" href="<%= origin %>" crossorigin />
@@ -94,26 +94,25 @@
94
94
  </script>
95
95
  <% } %>
96
96
 
97
- <link
98
- rel="preload"
99
- href="https://fonts.googleapis.com/icon?family=Material+Icons"
100
- as="style"
101
- onload="this.onload=null;this.rel='stylesheet';"
102
- />
103
- <noscript>
104
- <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
105
- </noscript>
106
-
97
+ <%# The space, fetched beside the page while the scripts load, rather than carried in it. %>
98
+ <% if (locals.spaceDocumentPath) { %>
99
+ <link id="plitzi-space" href="<%= locals.spaceDocumentPath %>" rel="preload" as="fetch" crossorigin="anonymous" />
100
+ <% } %>
107
101
  <link href="<%= locals.cssPath || '' %>" rel="preload" as="style" />
108
102
  <link href="<%= locals.cssPath || '' %>" rel="stylesheet" />
103
+ <%# The icons are a sheet of their own, after the SDK's: their fonts are files fetched only for a style a page uses. %>
104
+ <% if (locals.iconsCssPath) { %>
105
+ <link href="<%= locals.iconsCssPath %>" rel="stylesheet" />
106
+ <% } %>
109
107
  <% if (locals.builderCssPath) { %>
110
108
  <link href="<%= locals.builderCssPath %>" rel="preload" as="style" />
111
109
  <link href="<%= locals.builderCssPath %>" rel="stylesheet" />
112
110
  <% } %>
113
111
  <% if (locals.plugins && locals.plugins.length > 0) { %>
114
- <% for (const plugin of locals.plugins) { if (plugin.css) { %>
112
+ <%# A deferred plugin's stylesheet is loaded with its code, by the page that draws it. %>
113
+ <% for (const plugin of locals.plugins) { if (plugin.css && !plugin.deferred) { %>
115
114
  <link href="<%= plugin.css %>" rel="preload" as="style" />
116
- <link href="<%= plugin.css %>" rel="stylesheet" />
115
+ <link href="<%= plugin.css %>" rel="stylesheet" data-plitzi-plugin="<%= plugin.keyName %>" />
117
116
  <% } } %>
118
117
  <% } %>
119
118
  </head>
@@ -121,6 +120,59 @@
121
120
  <body>
122
121
  <noscript>You need to enable JavaScript to run this app.</noscript>
123
122
  <div id="plitzi" class="plitzi-root-container"><%- locals.html || '' %></div>
123
+ <%# An arrival that waits to be seen (`motion: { on: 'view' }`) is held at its start until the SDK sees it — after
124
+ its scripts, the space and hydration. One on screen as the page arrives would be content nobody can see until
125
+ then: seen here instead, as soon as it is parsed. Named by where it is rather than by `data-id`, which every
126
+ instance of a component shares; and in a sheet of its own rather than the element's `data-motion-seen`, which
127
+ React would hydrate as markup the server never drew. The sheet goes once the SDK has seen what it showed. %>
128
+ <script>
129
+ (() => {
130
+ const root = document.getElementById('plitzi');
131
+ const held = root.querySelectorAll('[data-motion-on="view"]:not([data-motion-seen])');
132
+ if (held.length === 0 || typeof IntersectionObserver !== 'function') {
133
+ return;
134
+ }
135
+
136
+ const sheet = document.head.appendChild(document.createElement('style'));
137
+ const shown = [];
138
+ const pathOf = element => {
139
+ const steps = [];
140
+ for (let node = element; node !== root; node = node.parentElement) {
141
+ steps.unshift(`:nth-child(${[...node.parentElement.children].indexOf(node) + 1})`);
142
+ }
143
+
144
+ return `#plitzi > ${steps.join(' > ')}:not([data-motion-seen])`;
145
+ };
146
+ const seen = new IntersectionObserver(entries => {
147
+ for (const { isIntersecting, target } of entries) {
148
+ if (isIntersecting) {
149
+ seen.unobserve(target);
150
+ shown.push(target);
151
+ const at = pathOf(target);
152
+ sheet.append(`${at}[data-motion-enter], ${at}[data-motion-stagger] > * { animation-play-state: running, paused; }\n`);
153
+ }
154
+ }
155
+ });
156
+ held.forEach(element => seen.observe(element));
157
+ const offScreen = element => {
158
+ const { top, bottom } = element.getBoundingClientRect();
159
+
160
+ return bottom <= 0 || top >= innerHeight;
161
+ };
162
+ const live = new MutationObserver(() => {
163
+ if (!root.querySelector('[data-hydrated]')) {
164
+ return;
165
+ }
166
+
167
+ seen.disconnect();
168
+ if (shown.every(element => !element.isConnected || element.hasAttribute('data-motion-seen') || offScreen(element))) {
169
+ sheet.remove();
170
+ live.disconnect();
171
+ }
172
+ });
173
+ live.observe(root, { attributes: true, subtree: true, attributeFilter: ['data-hydrated', 'data-motion-seen'] });
174
+ })();
175
+ </script>
124
176
  <% if (!locals.ssrOnly) { %>
125
177
  <%# The payload is data, so it travels as JSON: parsing it as a JavaScript literal cost the browser several times
126
178
  what JSON.parse does, on megabytes of it. Escaped by the server — no `<` reaches this block. %>
@@ -128,21 +180,71 @@
128
180
  <script type="module">
129
181
  import { render } from '<%= locals.jsPath || '' %>';
130
182
  <% const activePlugins = (locals.plugins || []).filter(p => p.js); %>
131
- <% for (const plugin of activePlugins) { %>
183
+ <% const json = value => JSON.stringify(value ?? null).replace(/</g, '\\u003c'); %>
184
+ <% for (const plugin of activePlugins) { if (!plugin.deferred) { %>
132
185
  import { default as <%= plugin.varName %> } from '<%= plugin.js %>';
133
- <% } %>
134
- const __plitziPlugins = { <%- activePlugins.map(p => `'${p.keyName}': {component: ${p.varName}, props: ${JSON.stringify(p.props)}, clientOnly: ${p.ssr === false}}`).join(', ') %> };
186
+ <% } } %>
187
+ <%# What the page draws, imported before it hydrates; the rest declared, and imported when a page draws it. %>
188
+ const __plitziPlugins = { <%- activePlugins.map(p => p.deferred
189
+ ? `'${p.keyName}': {load: () => import('${p.js}'), css: ${json(p.css)}, declaration: ${json(p.deferred)}, props: ${json(p.props)}, clientOnly: ${p.ssr === false}}`
190
+ : `'${p.keyName}': {component: ${p.varName}, props: ${json(p.props)}, clientOnly: ${p.ssr === false}}`).join(', ') %> };
135
191
  const __plitziData = document.getElementById('plitzi-ssr-data');
136
192
  const __plitziParams = JSON.parse(__plitziData.textContent);
137
193
  // Read once: the page would otherwise hold the space twice, as this text and as what it was parsed into.
138
194
  __plitziData.remove();
195
+ const __plitziSpace = document.getElementById('plitzi-space');
196
+ if (__plitziSpace) {
197
+ const response = await fetch(__plitziSpace.href);
198
+ if (!response.ok) {
199
+ throw new Error(`[plitzi] The space could not be loaded (${response.status}): the page stays as the server drew it.`);
200
+ }
201
+
202
+ __plitziParams.offlineData = await response.json();
203
+ }
204
+ <% if (locals.devReload) { %>
205
+ const __plitziApp = render('plitzi', __plitziParams, __plitziPlugins, <%= locals.debugMode ? true : false %>, true, { hotPlugins: true });
206
+ // A plugin the server built again: its new bundle swapped in where it is drawn and its stylesheet pointed at the
207
+ // new one — the rest of the page stays as it is. One that cannot be swapped in place loads the page again.
208
+ window.__plitziDevEvents?.addEventListener('plugin', async event => {
209
+ const { key, js, css } = JSON.parse(event.data);
210
+ const sheet = css && [...document.querySelectorAll('link[data-plitzi-plugin]')].find(link => link.dataset.plitziPlugin === key);
211
+ if (sheet) {
212
+ sheet.href = css;
213
+ }
214
+
215
+ try {
216
+ const { default: component } = await import(js);
217
+ if (!__plitziApp?.replacePlugin(key, component)) {
218
+ location.reload();
219
+ }
220
+ } catch (error) {
221
+ console.error(`[plitzi] plugin "${key}" could not be loaded again`, error);
222
+ }
223
+ });
224
+ <% } else { %>
139
225
  render('plitzi', __plitziParams, __plitziPlugins, <%= locals.debugMode ? true : false %>, true);
226
+ <% } %>
140
227
  </script>
141
228
  <% } %>
142
229
  <% if (locals.devReload) { %>
143
- <%# Development only: the server says the space changed (re-authored, `reloadPages`), and the page loads again. %>
230
+ <%# Development only: the server says the space changed (re-authored, `reloadPages`), and the page loads again — and
231
+ so does a page that reconnects to a server that restarted (a different `boot`): its code changed under it. %>
144
232
  <script>
145
- new EventSource('/__plitzi/reload').addEventListener('reload', () => location.reload());
233
+ (() => {
234
+ const events = new EventSource('/__plitzi/reload');
235
+ // The page's module script listens here too, for a plugin built again (`plugin`).
236
+ window.__plitziDevEvents = events;
237
+ let boot;
238
+ events.addEventListener('hello', event => {
239
+ const next = JSON.parse(event.data).boot;
240
+ if (boot && boot !== next) {
241
+ location.reload();
242
+ }
243
+
244
+ boot = next;
245
+ });
246
+ events.addEventListener('reload', () => location.reload());
247
+ })();
146
248
  </script>
147
249
  <% } %>
148
250
  </body>
@@ -2,6 +2,8 @@ import { writeFileAtomic } from "../helpers/atomicFile.js";
2
2
  import path from "node:path";
3
3
  import esbuild from "esbuild";
4
4
  import fs from "node:fs/promises";
5
+ import { pluginAssetLoaders, pluginImportQueries } from "@plitzi/sdk-shared/plugins/bundle";
6
+ import { inPluginLayer } from "@plitzi/sdk-shared/style/cssLayers";
5
7
  //#region src/plugins/compile.ts
6
8
  var EXTERNAL = [
7
9
  "react",
@@ -12,24 +14,6 @@ var EXTERNAL = [
12
14
  "@plitzi/sdk-shared"
13
15
  ];
14
16
  /**
15
- * Assets a stylesheet drags in with it, inlined.
16
- *
17
- * A plugin that imports a library's CSS — a map, a date picker, an editor — imports its images too, and without a
18
- * loader for them the build fails on a file the author never wrote. Inlined as data URIs rather than emitted
19
- * beside the bundle, because the plugin is served as exactly two files and a third that nothing routes to would
20
- * be a stylesheet with broken references in it.
21
- */
22
- var ASSET_LOADERS = {
23
- ".png": "dataurl",
24
- ".jpg": "dataurl",
25
- ".jpeg": "dataurl",
26
- ".gif": "dataurl",
27
- ".svg": "dataurl",
28
- ".webp": "dataurl",
29
- ".woff": "dataurl",
30
- ".woff2": "dataurl"
31
- };
32
- /**
33
17
  * Everything that went into the bundle, so a server can tell when any of it has moved on: a dev server as it is
34
18
  * edited, and any server — a deployment — when it finds a bundle built from something else (its content digest).
35
19
  *
@@ -40,14 +24,15 @@ var ASSET_LOADERS = {
40
24
  * Dependencies are left out. They change when something is installed, which is not an edit anybody is waiting to see,
41
25
  * and stat'ing a few thousand files on every cache miss to find that out is a real cost for no answer.
42
26
  */
43
- var sourceInputs = (metafile) => metafile ? Object.keys(metafile.inputs).filter((input) => !input.includes("node_modules")).map((input) => path.resolve(input)) : [];
27
+ var sourceInputs = (metafile) => metafile ? Object.keys(metafile.inputs).filter((input) => !input.includes("node_modules")).map((input) => path.resolve(input.replace(/^plitzi-(raw|inline):/, ""))) : [];
44
28
  var compilePlugin = async (jsPath, outDir, devMode = false) => {
45
29
  const result = await esbuild.build({
46
30
  entryPoints: [jsPath],
47
31
  bundle: true,
48
32
  format: "esm",
49
33
  external: EXTERNAL,
50
- loader: ASSET_LOADERS,
34
+ loader: pluginAssetLoaders(),
35
+ plugins: [pluginImportQueries()],
51
36
  outdir: outDir,
52
37
  entryNames: "index",
53
38
  jsx: "automatic",
@@ -58,7 +43,7 @@ var compilePlugin = async (jsPath, outDir, devMode = false) => {
58
43
  write: false
59
44
  });
60
45
  await fs.mkdir(outDir, { recursive: true });
61
- await Promise.all(result.outputFiles.map((output) => writeFileAtomic(output.path, output.contents)));
46
+ await Promise.all(result.outputFiles.map((output) => writeFileAtomic(output.path, output.path.endsWith(".css") ? inPluginLayer(output.text) : output.contents)));
62
47
  return {
63
48
  hasCSS: result.outputFiles.some((output) => path.basename(output.path) === "index.css"),
64
49
  inputs: sourceInputs(result.metafile)
@@ -0,0 +1 @@
1
+ export {};
@@ -1 +1,6 @@
1
1
  export declare const copyPlugin: (src: string, destDir: string, filename: string) => Promise<void>;
2
+ /**
3
+ * A plugin's stylesheet, copied or downloaded into the cache in the plugins' cascade layer — below the space's styles,
4
+ * as a compiled one is — whatever the file it came from says.
5
+ */
6
+ export declare const copyPluginStylesheet: (src: string, destDir: string) => Promise<void>;
@@ -1,5 +1,7 @@
1
1
  import { copyFileAtomic, writeFileAtomic } from "../helpers/atomicFile.js";
2
2
  import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { inPluginLayer } from "@plitzi/sdk-shared/style/cssLayers";
3
5
  //#region src/plugins/copy.ts
4
6
  var copyPlugin = async (src, destDir, filename) => {
5
7
  const dest = path.join(destDir, filename);
@@ -9,5 +11,18 @@ var copyPlugin = async (src, destDir, filename) => {
9
11
  await writeFileAtomic(dest, await res.text());
10
12
  } else await copyFileAtomic(src, dest);
11
13
  };
14
+ /**
15
+ * A plugin's stylesheet, copied or downloaded into the cache in the plugins' cascade layer — below the space's styles,
16
+ * as a compiled one is — whatever the file it came from says.
17
+ */
18
+ var copyPluginStylesheet = async (src, destDir) => {
19
+ let css;
20
+ if (src.startsWith("http://") || src.startsWith("https://")) {
21
+ const res = await fetch(src);
22
+ if (!res.ok) throw new Error(`[SSR] Plugin stylesheet fetch failed ${src}: ${res.status}`);
23
+ css = await res.text();
24
+ } else css = await fs.readFile(src, "utf8");
25
+ await writeFileAtomic(path.join(destDir, "index.css"), inPluginLayer(css));
26
+ };
12
27
  //#endregion
13
- export { copyPlugin };
28
+ export { copyPlugin, copyPluginStylesheet };
@@ -0,0 +1 @@
1
+ export {};
@@ -10,7 +10,20 @@ export declare class PluginManager {
10
10
  /** Maps base plugin name → most recently registered effective key (may include @version) */
11
11
  private readonly nameIndex;
12
12
  private readonly devMode;
13
+ /** Who is told what each built plugin was built from — a dev server's watcher (`watchPluginSources`). */
14
+ private readonly sourceListeners;
13
15
  constructor(plugins: Record<string, PluginSource>, cacheDir?: string, ttlMs?: number, devMode?: boolean);
16
+ /**
17
+ * Tells `listener` the files every built plugin was built from — those built so far, at once, and each one built or
18
+ * found built from now on. Only a compiled plugin has any: a copied or downloaded one is not this server's source.
19
+ */
20
+ onSources(listener: (key: string, inputs: readonly string[]) => void): () => void;
21
+ /**
22
+ * Builds a plugin again because its source changed — and again after a build that failed, which `prepare` otherwise
23
+ * never retries: the edit that fixes a syntax error is exactly the one a dev server must not ignore.
24
+ */
25
+ rebuild(name: string): Promise<PluginEntry | null>;
26
+ private remember;
14
27
  hasPlugin(name: string): boolean;
15
28
  register(name: string, source: PluginSource): void;
16
29
  /**
@@ -1,7 +1,7 @@
1
1
  import { serverLog } from "../helpers/serverLog.js";
2
2
  import { writeFileAtomic } from "../helpers/atomicFile.js";
3
3
  import { compilePlugin } from "./compile.js";
4
- import { copyPlugin } from "./copy.js";
4
+ import { copyPlugin, copyPluginStylesheet } from "./copy.js";
5
5
  import { detectAction, isComponentSource } from "./detect.js";
6
6
  import { assertPluginSources } from "./validate.js";
7
7
  import { createHash } from "node:crypto";
@@ -47,6 +47,8 @@ var PluginManager = class {
47
47
  /** Maps base plugin name → most recently registered effective key (may include @version) */
48
48
  nameIndex = /* @__PURE__ */ new Map();
49
49
  devMode = false;
50
+ /** Who is told what each built plugin was built from — a dev server's watcher (`watchPluginSources`). */
51
+ sourceListeners = /* @__PURE__ */ new Set();
50
52
  constructor(plugins, cacheDir, ttlMs, devMode = false) {
51
53
  assertPluginSources(plugins);
52
54
  this.plugins = plugins;
@@ -58,6 +60,36 @@ var PluginManager = class {
58
60
  this.nameIndex.set(baseName, key);
59
61
  }
60
62
  }
63
+ /**
64
+ * Tells `listener` the files every built plugin was built from — those built so far, at once, and each one built or
65
+ * found built from now on. Only a compiled plugin has any: a copied or downloaded one is not this server's source.
66
+ */
67
+ onSources(listener) {
68
+ this.sourceListeners.add(listener);
69
+ this.mem.forEach((cached, key) => {
70
+ if (cached.inputs?.length) listener(key, cached.inputs);
71
+ });
72
+ return () => {
73
+ this.sourceListeners.delete(listener);
74
+ };
75
+ }
76
+ /**
77
+ * Builds a plugin again because its source changed — and again after a build that failed, which `prepare` otherwise
78
+ * never retries: the edit that fixes a syntax error is exactly the one a dev server must not ignore.
79
+ */
80
+ async rebuild(name) {
81
+ const key = this.resolveKey(name) ?? name;
82
+ this.mem.delete(key);
83
+ this.failed.delete(key);
84
+ return this.prepare(key);
85
+ }
86
+ remember(key, cached) {
87
+ this.mem.set(key, cached);
88
+ const { inputs } = cached;
89
+ if (inputs?.length) this.sourceListeners.forEach((listener) => {
90
+ listener(key, inputs);
91
+ });
92
+ }
61
93
  hasPlugin(name) {
62
94
  return this.resolveKey(name) !== null;
63
95
  }
@@ -272,7 +304,7 @@ var PluginManager = class {
272
304
  if (await this.fileExists(path.join(this.pluginDir(key), "index.css"))) cssUrl = this.assetUrl(key, "index.css", meta.compiledAt);
273
305
  else if (sourceCss && this.isWebUrl(sourceCss)) cssUrl = sourceCss;
274
306
  const entry = this.toEntry(key, true, cssUrl, source.props, meta.compiledAt);
275
- this.mem.set(key, {
307
+ this.remember(key, {
276
308
  compiledAt: meta.compiledAt,
277
309
  entry,
278
310
  inputs: meta.inputs
@@ -303,7 +335,7 @@ var PluginManager = class {
303
335
  else if (cssPath) {
304
336
  if (this.isWebUrl(cssPath)) cssUrl = cssPath;
305
337
  else {
306
- await copyPlugin(cssPath, dir, "index.css");
338
+ await copyPluginStylesheet(cssPath, dir);
307
339
  cssUrl = `${this.urlPrefix}/${name}/index.css`;
308
340
  }
309
341
  }
@@ -312,12 +344,9 @@ var PluginManager = class {
312
344
  if (!jsRes.ok) throw new Error(`HTTP ${jsRes.status} downloading ${jsPath}`);
313
345
  await writeFileAtomic(path.join(dir, "index.js"), await jsRes.text());
314
346
  if (cssPath) {
315
- if (cssPath.startsWith("http://") || cssPath.startsWith("https://")) {
316
- const cssRes = await fetch(cssPath);
317
- if (!cssRes.ok) throw new Error(`HTTP ${cssRes.status} downloading ${cssPath}`);
318
- await writeFileAtomic(path.join(dir, "index.css"), await cssRes.text());
319
- } else if (this.isWebUrl(cssPath)) cssUrl = cssPath;
320
- else await copyPlugin(cssPath, dir, "index.css");
347
+ if (cssPath.startsWith("http://") || cssPath.startsWith("https://")) await copyPluginStylesheet(cssPath, dir);
348
+ else if (this.isWebUrl(cssPath)) cssUrl = cssPath;
349
+ else await copyPluginStylesheet(cssPath, dir);
321
350
  cssUrl = `${this.urlPrefix}/${name}/index.css`;
322
351
  }
323
352
  } else {
@@ -325,7 +354,7 @@ var PluginManager = class {
325
354
  if (cssPath) {
326
355
  if (this.isWebUrl(cssPath)) cssUrl = cssPath;
327
356
  else {
328
- await copyPlugin(cssPath, dir, "index.css");
357
+ await copyPluginStylesheet(cssPath, dir);
329
358
  cssUrl = `${this.urlPrefix}/${name}/index.css`;
330
359
  }
331
360
  }
@@ -340,7 +369,7 @@ var PluginManager = class {
340
369
  });
341
370
  if (cssUrl?.startsWith(this.urlPrefix)) cssUrl = this.assetUrl(name, "index.css", compiledAt);
342
371
  const entry = this.toEntry(name, true, cssUrl, source.props, compiledAt);
343
- this.mem.set(name, {
372
+ this.remember(name, {
344
373
  compiledAt,
345
374
  entry,
346
375
  inputs: buildInputs