@ecopages/lit 0.2.1-beta.0 → 0.3.0

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.
@@ -0,0 +1,181 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { fileURLToPath } from 'node:url';
3
+ const workerShutdownTimeoutMs = 30_000;
4
+ const workerExitTimeoutMs = 1_000;
5
+ export class LitStaticRenderWorkerClient {
6
+ configModulePath;
7
+ runtimeOrigin;
8
+ worker = null;
9
+ workerExitPromise = null;
10
+ workerExitCode = null;
11
+ readyPromise = null;
12
+ pending = new Map();
13
+ nextRenderId = 0;
14
+ shutdownHandler = null;
15
+ constructor({ configModulePath, runtimeOrigin }) {
16
+ this.configModulePath = configModulePath;
17
+ this.runtimeOrigin = runtimeOrigin;
18
+ }
19
+ rejectPending(error) {
20
+ for (const pending of this.pending.values()) {
21
+ pending.reject(error);
22
+ }
23
+ this.pending.clear();
24
+ }
25
+ async start() {
26
+ if (this.readyPromise) {
27
+ return this.readyPromise;
28
+ }
29
+ this.readyPromise = new Promise((resolveReady, rejectReady) => {
30
+ const worker = new Worker(new URL('./lit-static-render-worker.js', import.meta.url), {
31
+ execArgv: [...process.execArgv],
32
+ env: {
33
+ ...process.env,
34
+ ECOPAGES_LIT_STATIC_RENDER_WORKER: 'true',
35
+ },
36
+ });
37
+ this.worker = worker;
38
+ this.workerExitCode = null;
39
+ this.workerExitPromise = new Promise((resolveExit) => {
40
+ worker.once('exit', resolveExit);
41
+ });
42
+ worker.on('message', (message) => {
43
+ if (message.type === 'ready') {
44
+ resolveReady();
45
+ return;
46
+ }
47
+ if (message.type === 'shutdown-complete') {
48
+ this.shutdownHandler?.resolve();
49
+ this.shutdownHandler = null;
50
+ return;
51
+ }
52
+ if (message.type === 'error') {
53
+ if (message.id && this.pending.has(message.id)) {
54
+ const pending = this.pending.get(message.id);
55
+ this.pending.delete(message.id);
56
+ pending.reject(new Error(message.message));
57
+ return;
58
+ }
59
+ const error = new Error(message.message);
60
+ this.shutdownHandler?.reject(error);
61
+ this.shutdownHandler = null;
62
+ rejectReady(error);
63
+ return;
64
+ }
65
+ if (message.type === 'result') {
66
+ const pending = this.pending.get(message.id);
67
+ if (!pending) {
68
+ return;
69
+ }
70
+ this.pending.delete(message.id);
71
+ pending.resolve({
72
+ html: message.html,
73
+ cacheStrategy: message.cacheStrategy,
74
+ sourceDependencyPaths: message.sourceDependencyPaths,
75
+ metadata: message.metadata,
76
+ });
77
+ }
78
+ });
79
+ worker.on('error', (error) => {
80
+ rejectReady(error instanceof Error ? error : new Error(String(error)));
81
+ });
82
+ worker.on('exit', (code) => {
83
+ this.workerExitCode = code;
84
+ const exitError = new Error(code === 0
85
+ ? 'Lit static render worker exited'
86
+ : `Lit static render worker exited with code ${code}`);
87
+ this.rejectPending(exitError);
88
+ this.shutdownHandler?.reject(exitError);
89
+ this.shutdownHandler = null;
90
+ if (code !== 0) {
91
+ rejectReady(exitError);
92
+ }
93
+ });
94
+ const initMessage = {
95
+ type: 'init',
96
+ configModulePath: this.configModulePath,
97
+ runtimeOrigin: this.runtimeOrigin,
98
+ };
99
+ worker.postMessage(initMessage);
100
+ });
101
+ return this.readyPromise;
102
+ }
103
+ async renderPage(input) {
104
+ await this.start();
105
+ const worker = this.worker;
106
+ if (!worker) {
107
+ throw new Error('Lit static render worker is not available');
108
+ }
109
+ const id = String(++this.nextRenderId);
110
+ return new Promise((resolve, reject) => {
111
+ this.pending.set(id, { resolve, reject });
112
+ const renderMessage = {
113
+ type: 'render',
114
+ id,
115
+ filePath: input.filePath,
116
+ params: input.params,
117
+ query: input.query,
118
+ };
119
+ worker.postMessage(renderMessage);
120
+ });
121
+ }
122
+ async dispose() {
123
+ if (!this.worker) {
124
+ return;
125
+ }
126
+ this.rejectPending(new Error('Lit static render worker was disposed'));
127
+ const worker = this.worker;
128
+ const workerExit = this.workerExitPromise ?? Promise.resolve(this.workerExitCode ?? 0);
129
+ this.worker = null;
130
+ this.workerExitPromise = null;
131
+ this.readyPromise = null;
132
+ if (this.workerExitCode !== null) {
133
+ if (this.workerExitCode !== 0) {
134
+ throw new Error(`Lit static render worker exited with code ${this.workerExitCode}`);
135
+ }
136
+ return;
137
+ }
138
+ let shutdownTimeout;
139
+ const shutdownComplete = new Promise((resolve, reject) => {
140
+ shutdownTimeout = setTimeout(() => reject(new Error('Lit static render worker shutdown timed out')), workerShutdownTimeoutMs);
141
+ this.shutdownHandler = {
142
+ resolve: () => {
143
+ if (shutdownTimeout)
144
+ clearTimeout(shutdownTimeout);
145
+ resolve();
146
+ },
147
+ reject: (error) => {
148
+ if (shutdownTimeout)
149
+ clearTimeout(shutdownTimeout);
150
+ reject(error);
151
+ },
152
+ };
153
+ });
154
+ worker.postMessage({ type: 'shutdown' });
155
+ let shutdownError;
156
+ try {
157
+ await shutdownComplete;
158
+ }
159
+ catch (error) {
160
+ shutdownError = error instanceof Error ? error : new Error(String(error));
161
+ }
162
+ if (shutdownTimeout)
163
+ clearTimeout(shutdownTimeout);
164
+ const exitResult = await new Promise((resolve) => {
165
+ const exitTimeout = setTimeout(() => resolve({ timedOut: true }), workerExitTimeoutMs);
166
+ void workerExit.then((code) => {
167
+ clearTimeout(exitTimeout);
168
+ resolve({ code });
169
+ });
170
+ });
171
+ if ('timedOut' in exitResult) {
172
+ await worker.terminate();
173
+ }
174
+ else if (exitResult.code !== 0) {
175
+ shutdownError ??= new Error(`Lit static render worker exited with code ${exitResult.code}`);
176
+ }
177
+ if (shutdownError)
178
+ throw shutdownError;
179
+ }
180
+ }
181
+ export const litStaticRenderWorkerEntryPath = fileURLToPath(new URL('./lit-static-render-worker.js', import.meta.url));
@@ -0,0 +1 @@
1
+ import './dom-shim.js';
@@ -0,0 +1,111 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import { loadEcoPagesConfig } from '@ecopages/core/config';
3
+ import { setupAppRuntimeIntegrations, setupAppRuntimePlugins, teardownAppRuntimeIntegrations, } from '@ecopages/core/build';
4
+ import { installBuildQueue } from '@ecopages/core/build';
5
+ import { requireAppRuntime } from '@ecopages/core/integration';
6
+ import { RouteRendererFactory } from '@ecopages/core/integration';
7
+ import { toLitStaticRenderResult } from './lit-static-render-result.js';
8
+ import './dom-shim.js';
9
+ let appConfig = null;
10
+ let routeRendererFactory = null;
11
+ let activeRenders = 0;
12
+ let shutdownRequested = false;
13
+ let resolveRendersIdle;
14
+ /**
15
+ * Loads app config in the worker isolate and bootstraps rendering.
16
+ *
17
+ * @remarks
18
+ * Processor `.setup()` is skipped here because the main thread already prepared disk artifacts
19
+ * before the Lit worker started (`ECOPAGES_LIT_STATIC_RENDER_WORKER`). Integrations still need
20
+ * setup in this isolate before route renderers are created.
21
+ */
22
+ async function initializeWorker(configModulePath, runtimeOrigin) {
23
+ appConfig = await loadEcoPagesConfig({ configFile: configModulePath });
24
+ const runtime = requireAppRuntime(appConfig);
25
+ runtime.origin = runtimeOrigin;
26
+ await setupAppRuntimePlugins({
27
+ appConfig,
28
+ });
29
+ await setupAppRuntimeIntegrations({ appConfig });
30
+ installBuildQueue(appConfig);
31
+ routeRendererFactory = new RouteRendererFactory({
32
+ appConfig,
33
+ runtime,
34
+ });
35
+ }
36
+ async function renderPage(filePath, params, query) {
37
+ if (!routeRendererFactory) {
38
+ throw new Error('Lit static render worker is not initialized');
39
+ }
40
+ const result = await routeRendererFactory.getPageRenderer(filePath).execute({
41
+ file: filePath,
42
+ params,
43
+ query,
44
+ });
45
+ return toLitStaticRenderResult(result);
46
+ }
47
+ async function shutdownWorker() {
48
+ shutdownRequested = true;
49
+ if (activeRenders > 0) {
50
+ await new Promise((resolve) => {
51
+ resolveRendersIdle = resolve;
52
+ });
53
+ }
54
+ try {
55
+ if (appConfig) {
56
+ await teardownAppRuntimeIntegrations({ appConfig });
57
+ }
58
+ postMessage({ type: 'shutdown-complete' });
59
+ }
60
+ catch (error) {
61
+ const errorMessage = error instanceof Error ? error.message : String(error);
62
+ postMessage({ type: 'error', message: errorMessage });
63
+ }
64
+ finally {
65
+ parentPort?.close();
66
+ }
67
+ }
68
+ function postMessage(message) {
69
+ parentPort?.postMessage(message);
70
+ }
71
+ if (!parentPort) {
72
+ throw new Error('Lit static render worker must be started as a worker thread');
73
+ }
74
+ parentPort.on('message', async (message) => {
75
+ try {
76
+ if (message.type === 'init') {
77
+ await initializeWorker(message.configModulePath, message.runtimeOrigin);
78
+ postMessage({ type: 'ready' });
79
+ return;
80
+ }
81
+ if (message.type === 'shutdown') {
82
+ await shutdownWorker();
83
+ return;
84
+ }
85
+ if (message.type === 'render') {
86
+ if (shutdownRequested) {
87
+ throw new Error('Lit static render worker is shutting down');
88
+ }
89
+ activeRenders += 1;
90
+ try {
91
+ const result = await renderPage(message.filePath, message.params, message.query);
92
+ postMessage({ type: 'result', id: message.id, ...result });
93
+ }
94
+ finally {
95
+ activeRenders -= 1;
96
+ if (activeRenders === 0) {
97
+ resolveRendersIdle?.();
98
+ resolveRendersIdle = undefined;
99
+ }
100
+ }
101
+ }
102
+ }
103
+ catch (error) {
104
+ const errorMessage = error instanceof Error ? error.message : String(error);
105
+ if (message.type === 'render') {
106
+ postMessage({ type: 'error', id: message.id, message: errorMessage });
107
+ return;
108
+ }
109
+ postMessage({ type: 'error', message: errorMessage });
110
+ }
111
+ });
@@ -1,4 +1 @@
1
- const LIT_PLUGIN_NAME = "lit";
2
- export {
3
- LIT_PLUGIN_NAME
4
- };
1
+ export const LIT_PLUGIN_NAME = 'lit';
@@ -3,8 +3,8 @@
3
3
  * @module
4
4
  */
5
5
  import './console.js';
6
- import { IntegrationPlugin, type IntegrationPluginConfig } from '@ecopages/core/plugins/integration-plugin';
7
- import { type AssetDefinition } from '@ecopages/core/services/asset-processing-service';
6
+ import { IntegrationPlugin, type IntegrationRendererBase, type IntegrationRuntimeContext, type IntegrationPluginConfig, type StaticExportContext, type StaticExportRelease } from '@ecopages/core/integration';
7
+ import { type AssetDefinition } from '@ecopages/core/integration';
8
8
  import { LitRenderer } from './lit-renderer.js';
9
9
  /**
10
10
  * The name of the Lit plugin
@@ -13,11 +13,44 @@ export declare const PLUGIN_NAME = "lit";
13
13
  /**
14
14
  * The Lit plugin class
15
15
  * This plugin provides support for Lit components in Ecopages
16
+ *
17
+ * @remarks
18
+ * Owns the {@link LitStaticRenderSession} for worker SSR. Renderers receive a
19
+ * lazy `getRenderSession` accessor so construction before {@link setup} still
20
+ * observes the session once setup assigns it.
16
21
  */
17
22
  export declare class LitPlugin extends IntegrationPlugin {
18
- renderer: typeof LitRenderer;
23
+ renderer: new (options: import("@ecopages/core/integration").IntegrationRendererOptions) => import("@ecopages/core/integration").IntegrationRenderer<import("@ecopages/core").EcoPagesElement> & {
24
+ name: string;
25
+ };
26
+ private renderSession;
19
27
  constructor(options?: Omit<IntegrationPluginConfig, 'name'>);
28
+ /**
29
+ * Returns the global integration dependencies required in the browser.
30
+ *
31
+ * @remarks
32
+ * Injects Lit's hydration support script into the document head before any
33
+ * custom element connects. While `@lit-labs/ssr-client/lit-element-hydrate-support.js`
34
+ * can be resolved directly in modern Bun and Node environments, an inline self-contained
35
+ * script is currently emitted to ensure synchronous availability before custom-element
36
+ * registration without requiring an import map for `@lit-labs/ssr-client` subpath imports.
37
+ */
20
38
  getDependencies(): AssetDefinition[];
39
+ private createRenderSession;
40
+ createRenderer(base: IntegrationRendererBase): LitRenderer;
41
+ hmr(context: IntegrationRuntimeContext): void;
42
+ setup(context: IntegrationRuntimeContext): Promise<void>;
43
+ teardown(): Promise<void>;
44
+ /**
45
+ * Reconciles the render worker with the export origin and preloads Lit routes.
46
+ *
47
+ * @remarks
48
+ * `setup()` can start the session with the runtime origin before static export resolves its own base URL. The
49
+ * session reuses the worker when the identities match and restarts it when the export origin differs. The returned
50
+ * release stops the worker once the export ends, so an in-process export leaves no worker thread running. A later
51
+ * render in the same process falls back to rendering in-process.
52
+ */
53
+ staticExport(context: StaticExportContext): Promise<StaticExportRelease>;
21
54
  }
22
55
  /**
23
56
  * Factory function to create a Lit plugin instance
package/src/lit.plugin.js CHANGED
@@ -1,51 +1,141 @@
1
- import "./console.js";
2
- import { IntegrationPlugin } from "@ecopages/core/plugins/integration-plugin";
3
- import { AssetFactory } from "@ecopages/core/services/asset-processing-service";
4
- import { litElementHydrateScript } from "./lit-element-hydrate.js";
5
- import { LIT_PLUGIN_NAME } from "./lit.constants.js";
6
- import { LitRenderer } from "./lit-renderer.js";
7
- const PLUGIN_NAME = LIT_PLUGIN_NAME;
8
- class LitPlugin extends IntegrationPlugin {
9
- renderer = LitRenderer;
10
- constructor(options) {
11
- super({
12
- name: PLUGIN_NAME,
13
- extensions: [".lit.tsx"],
14
- staticBuildStep: "fetch",
15
- ...options
16
- });
17
- this.integrationDependencies.unshift(...this.getDependencies());
18
- }
19
- getDependencies() {
20
- return [
21
- /**
22
- * BUG ALERT
23
- * Due to an issue appeared in Bun 1.2.2, we need to use a workaround to import the hydrate script.
24
- * This is a temporary solution until the issue is resolved.
25
- * The litElementHydrateScript is the same file built on Bun 1.1.45.
26
- * https://github.com/oven-sh/bun/issues/17180
27
- *
28
- * AssetFactory.createNodeModuleScript({
29
- * position: 'head',
30
- * importPath: '@lit-labs/ssr-client/lit-element-hydrate-support.js'
31
- * })
32
- */
33
- AssetFactory.createInlineContentScript({
34
- position: "head",
35
- content: `(() => {${litElementHydrateScript}})();`,
36
- bundle: false,
37
- attributes: {
38
- "data-eco-script-id": "lit-hydrate-support"
1
+ import { CUSTOM_ELEMENT_SSR_PRELOAD_CACHE_SCOPES, invalidateCustomElementScriptPreload, } from '@ecopages/core/integration';
2
+ /**
3
+ * This module contains the Lit plugin
4
+ * @module
5
+ */
6
+ import './console.js';
7
+ import { IntegrationPlugin, } from '@ecopages/core/integration';
8
+ import { isLitStaticRenderWorkerThread } from '@ecopages/core/build';
9
+ import path from 'node:path';
10
+ import { AssetFactory } from '@ecopages/core/integration';
11
+ import { litElementHydrateScript } from './lit-element-hydrate.js';
12
+ import { LIT_PLUGIN_NAME } from './lit.constants.js';
13
+ import { createLitRenderer, LitRenderer } from './lit-renderer.js';
14
+ import { LitStaticRenderSession } from './lit-static-render-session.js';
15
+ /**
16
+ * The name of the Lit plugin
17
+ */
18
+ export const PLUGIN_NAME = LIT_PLUGIN_NAME;
19
+ /**
20
+ * The Lit plugin class
21
+ * This plugin provides support for Lit components in Ecopages
22
+ *
23
+ * @remarks
24
+ * Owns the {@link LitStaticRenderSession} for worker SSR. Renderers receive a
25
+ * lazy `getRenderSession` accessor so construction before {@link setup} still
26
+ * observes the session once setup assigns it.
27
+ */
28
+ export class LitPlugin extends IntegrationPlugin {
29
+ renderer = LitRenderer;
30
+ renderSession = null;
31
+ constructor(options) {
32
+ super({
33
+ name: PLUGIN_NAME,
34
+ extensions: ['.lit.tsx'],
35
+ ...options,
36
+ });
37
+ this.integrationDependencies.unshift(...this.getDependencies());
38
+ }
39
+ /**
40
+ * Returns the global integration dependencies required in the browser.
41
+ *
42
+ * @remarks
43
+ * Injects Lit's hydration support script into the document head before any
44
+ * custom element connects. While `@lit-labs/ssr-client/lit-element-hydrate-support.js`
45
+ * can be resolved directly in modern Bun and Node environments, an inline self-contained
46
+ * script is currently emitted to ensure synchronous availability before custom-element
47
+ * registration without requiring an import map for `@lit-labs/ssr-client` subpath imports.
48
+ */
49
+ getDependencies() {
50
+ return [
51
+ AssetFactory.createInlineContentScript({
52
+ position: 'head',
53
+ content: `(() => {${litElementHydrateScript}})();`,
54
+ bundle: false,
55
+ attributes: {
56
+ 'data-eco-script-id': 'lit-hydrate-support',
57
+ },
58
+ }),
59
+ ];
60
+ }
61
+ createRenderSession() {
62
+ if (!this.appConfig || !this.runtime || !this.assetProcessingService) {
63
+ throw new Error('Lit plugin must be initialized before starting the render worker');
39
64
  }
40
- })
41
- ];
42
- }
65
+ const assetProcessingService = this.assetProcessingService;
66
+ return new LitStaticRenderSession({
67
+ preloadScopes: this.runtime.customElementSsrPreloadScopes,
68
+ integration: this,
69
+ resolveDependencyPath: (componentDir, sourcePath) => path.join(componentDir, sourcePath),
70
+ processDependencies: (dependencies, integration) => assetProcessingService.processDependencies(dependencies, integration),
71
+ getInvalidationVersion: () => this.runtime?.moduleLoader.getDevelopmentGeneration() ?? 0,
72
+ });
73
+ }
74
+ createRenderer(base) {
75
+ const Renderer = this.renderer === LitRenderer ? createLitRenderer(() => this.renderSession ?? undefined) : this.renderer;
76
+ const renderer = new Renderer(base);
77
+ renderer.name ||= this.name;
78
+ return renderer;
79
+ }
80
+ hmr(context) {
81
+ super.hmr(context);
82
+ const manager = context.runtime.hmr;
83
+ if (!manager)
84
+ return;
85
+ const preloadScopes = context.runtime.customElementSsrPreloadScopes;
86
+ manager.onScriptEntrypointChanged?.((scriptPath) => {
87
+ invalidateCustomElementScriptPreload(preloadScopes, scriptPath, CUSTOM_ELEMENT_SSR_PRELOAD_CACHE_SCOPES.lit);
88
+ });
89
+ }
90
+ async setup(context) {
91
+ await super.setup(context);
92
+ if (!this.appConfig?.absolutePaths?.config) {
93
+ throw new Error('[ecopages][lit] Lit server rendering requires appConfig.absolutePaths.config');
94
+ }
95
+ if (isLitStaticRenderWorkerThread()) {
96
+ return;
97
+ }
98
+ this.renderSession ??= this.createRenderSession();
99
+ await this.renderSession.ensureWorker({
100
+ configModulePath: this.appConfig.absolutePaths.config,
101
+ runtimeOrigin: context.runtime.origin,
102
+ });
103
+ }
104
+ async teardown() {
105
+ await this.renderSession?.dispose();
106
+ this.renderSession = null;
107
+ await super.teardown();
108
+ }
109
+ /**
110
+ * Reconciles the render worker with the export origin and preloads Lit routes.
111
+ *
112
+ * @remarks
113
+ * `setup()` can start the session with the runtime origin before static export resolves its own base URL. The
114
+ * session reuses the worker when the identities match and restarts it when the export origin differs. The returned
115
+ * release stops the worker once the export ends, so an in-process export leaves no worker thread running. A later
116
+ * render in the same process falls back to rendering in-process.
117
+ */
118
+ async staticExport(context) {
119
+ this.renderSession ??= this.createRenderSession();
120
+ await this.renderSession.ensureWorker({
121
+ configModulePath: context.appConfig.absolutePaths.config,
122
+ runtimeOrigin: context.baseUrl,
123
+ });
124
+ const session = this.renderSession;
125
+ await session.preloadStaticRoutes(context);
126
+ return async () => {
127
+ await session.dispose();
128
+ if (this.renderSession === session) {
129
+ this.renderSession = null;
130
+ }
131
+ };
132
+ }
43
133
  }
44
- function litPlugin(options) {
45
- return new LitPlugin(options);
134
+ /**
135
+ * Factory function to create a Lit plugin instance
136
+ * @param options Configuration options for the Lit plugin
137
+ * @returns A new LitPlugin instance
138
+ */
139
+ export function litPlugin(options) {
140
+ return new LitPlugin(options);
46
141
  }
47
- export {
48
- LitPlugin,
49
- PLUGIN_NAME,
50
- litPlugin
51
- };
@@ -1,13 +1,21 @@
1
- import { unsafeCSS } from "lit";
2
- const StyledMixin = (superClass, css = []) => {
3
- class StyledMixinClass extends superClass {
4
- static styles = [
5
- superClass.styles ?? [],
6
- ...css.map((styles) => unsafeCSS(styles))
7
- ];
8
- }
9
- return StyledMixinClass;
10
- };
11
- export {
12
- StyledMixin
1
+ /**
2
+ * This module contains StyledMixin
3
+ * It is used to add styles to a LitElement
4
+ * @module
5
+ */
6
+ import { unsafeCSS } from 'lit';
7
+ /**
8
+ * A mixin to add styles to a LitElement
9
+ * @param superClass - The class to mix into
10
+ * @param css - The styles to add
11
+ * @returns The class with the styles added
12
+ */
13
+ export const StyledMixin = (superClass, css = []) => {
14
+ class StyledMixinClass extends superClass {
15
+ static styles = [
16
+ superClass.styles ?? [],
17
+ ...css.map((styles) => unsafeCSS(styles)),
18
+ ];
19
+ }
20
+ return StyledMixinClass;
13
21
  };