pulse-js-framework 1.7.15 → 1.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/cli/help.js +583 -0
- package/cli/index.js +24 -105
- package/package.json +3 -2
- package/runtime/async.js +39 -0
- package/runtime/dom-element.js +107 -0
- package/runtime/index.js +2 -0
- package/runtime/pulse.js +40 -0
- package/runtime/ssr-async.js +229 -0
- package/runtime/ssr-hydrator.js +310 -0
- package/runtime/ssr-serializer.js +266 -0
- package/runtime/ssr.js +463 -0
package/cli/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { dirname, join, resolve, relative } from 'path';
|
|
|
9
9
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, watch } from 'fs';
|
|
10
10
|
import { log } from './logger.js';
|
|
11
11
|
import { findPulseFiles, parseArgs } from './utils/file-utils.js';
|
|
12
|
+
import { runHelp } from './help.js';
|
|
12
13
|
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = dirname(__filename);
|
|
@@ -136,10 +137,28 @@ function suggestCommand(input) {
|
|
|
136
137
|
*/
|
|
137
138
|
async function main() {
|
|
138
139
|
const args = process.argv.slice(2);
|
|
139
|
-
|
|
140
|
+
let command = args[0] || 'help';
|
|
141
|
+
|
|
142
|
+
// Handle global --help and -h flags
|
|
143
|
+
if (command === '--help' || command === '-h') {
|
|
144
|
+
command = 'help';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Handle --version and -v flags
|
|
148
|
+
if (command === '--version' || command === '-v') {
|
|
149
|
+
command = 'version';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Handle command-specific help: pulse <cmd> --help or pulse <cmd> -h
|
|
153
|
+
const cmdArgs = args.slice(1);
|
|
154
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) {
|
|
155
|
+
// Show help for the specific command
|
|
156
|
+
await commands.help([command]);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
140
159
|
|
|
141
160
|
if (command in commands) {
|
|
142
|
-
await commands[command](
|
|
161
|
+
await commands[command](cmdArgs);
|
|
143
162
|
} else {
|
|
144
163
|
log.error(`Unknown command: ${command}`);
|
|
145
164
|
|
|
@@ -156,110 +175,10 @@ async function main() {
|
|
|
156
175
|
|
|
157
176
|
/**
|
|
158
177
|
* Show help message
|
|
178
|
+
* Supports: pulse help, pulse help <command>
|
|
159
179
|
*/
|
|
160
|
-
function showHelp() {
|
|
161
|
-
|
|
162
|
-
Pulse Framework CLI v${VERSION}
|
|
163
|
-
|
|
164
|
-
Usage: pulse <command> [options]
|
|
165
|
-
|
|
166
|
-
Commands:
|
|
167
|
-
create <name> Create a new Pulse project
|
|
168
|
-
init [options] Initialize project in current directory
|
|
169
|
-
dev [port] Start development server (default: 3000)
|
|
170
|
-
build Build for production (minified)
|
|
171
|
-
preview [port] Preview production build (default: 4173)
|
|
172
|
-
compile <file> Compile a .pulse file to JavaScript
|
|
173
|
-
mobile <cmd> Mobile app commands (init, build, run)
|
|
174
|
-
lint [files] Validate .pulse files for errors and style
|
|
175
|
-
format [files] Format .pulse files consistently
|
|
176
|
-
analyze Analyze bundle size and dependencies
|
|
177
|
-
test [files] Run tests with coverage support
|
|
178
|
-
doctor Run project diagnostics
|
|
179
|
-
scaffold <type> Generate components, pages, stores
|
|
180
|
-
docs Generate API documentation from JSDoc
|
|
181
|
-
release <type> Create a new release (patch, minor, major)
|
|
182
|
-
docs-test Test documentation (syntax, imports, HTTP)
|
|
183
|
-
version Show version number
|
|
184
|
-
help Show this help message
|
|
185
|
-
|
|
186
|
-
Create/Init Options:
|
|
187
|
-
--typescript Create TypeScript project
|
|
188
|
-
--minimal Create minimal project structure
|
|
189
|
-
|
|
190
|
-
Compile Options:
|
|
191
|
-
--watch, -w Watch files and recompile on changes
|
|
192
|
-
--dry-run Show what would be compiled without writing
|
|
193
|
-
--output, -o Output directory (default: same as input)
|
|
194
|
-
|
|
195
|
-
Lint Options:
|
|
196
|
-
--fix Auto-fix fixable issues
|
|
197
|
-
--watch, -w Watch files and re-lint on changes
|
|
198
|
-
--dry-run Show fixes without applying (use with --fix)
|
|
199
|
-
|
|
200
|
-
Format Options:
|
|
201
|
-
--check Check formatting without writing (dry-run)
|
|
202
|
-
--watch, -w Watch files and re-format on changes
|
|
203
|
-
--write Write formatted output (default)
|
|
204
|
-
|
|
205
|
-
Analyze Options:
|
|
206
|
-
--json Output analysis as JSON
|
|
207
|
-
--verbose Show detailed metrics
|
|
208
|
-
|
|
209
|
-
Test Options:
|
|
210
|
-
--coverage, -c Collect code coverage
|
|
211
|
-
--watch, -w Watch files and re-run tests
|
|
212
|
-
--filter, -f Filter tests by name pattern
|
|
213
|
-
--timeout, -t Test timeout in ms (default: 30000)
|
|
214
|
-
--bail, -b Stop on first failure
|
|
215
|
-
--create <name> Generate a new test file
|
|
216
|
-
|
|
217
|
-
Doctor Options:
|
|
218
|
-
--verbose, -v Show detailed diagnostics
|
|
219
|
-
--json Output as JSON
|
|
220
|
-
|
|
221
|
-
Scaffold Options:
|
|
222
|
-
--dir, -d <path> Output directory
|
|
223
|
-
--force, -f Overwrite existing files
|
|
224
|
-
--props Include props section (components)
|
|
225
|
-
|
|
226
|
-
Docs Options:
|
|
227
|
-
--generate, -g Generate documentation
|
|
228
|
-
--format, -f Output format: markdown, json, html
|
|
229
|
-
--output, -o Output directory (default: docs/api)
|
|
230
|
-
|
|
231
|
-
Release Options:
|
|
232
|
-
--dry-run Show what would be done without making changes
|
|
233
|
-
--no-push Create commit and tag but don't push
|
|
234
|
-
--title <text> Release title for changelog
|
|
235
|
-
--skip-prompt Use empty changelog (for automation)
|
|
236
|
-
--skip-docs-test Skip documentation tests before release
|
|
237
|
-
--from-commits Auto-extract changelog from git commits since last tag
|
|
238
|
-
|
|
239
|
-
Examples:
|
|
240
|
-
pulse create my-app
|
|
241
|
-
pulse create my-app --typescript
|
|
242
|
-
pulse init --typescript
|
|
243
|
-
pulse dev
|
|
244
|
-
pulse build
|
|
245
|
-
pulse test
|
|
246
|
-
pulse test --coverage --watch
|
|
247
|
-
pulse test --create MyComponent
|
|
248
|
-
pulse doctor
|
|
249
|
-
pulse doctor --verbose
|
|
250
|
-
pulse scaffold component Button
|
|
251
|
-
pulse scaffold page Dashboard
|
|
252
|
-
pulse scaffold store user
|
|
253
|
-
pulse docs --generate
|
|
254
|
-
pulse docs --generate --format html
|
|
255
|
-
pulse compile src/App.pulse
|
|
256
|
-
pulse lint src/ --fix
|
|
257
|
-
pulse format --check
|
|
258
|
-
pulse analyze --json
|
|
259
|
-
pulse release patch
|
|
260
|
-
|
|
261
|
-
Documentation: https://github.com/vincenthirtz/pulse-js-framework
|
|
262
|
-
`);
|
|
180
|
+
function showHelp(args = []) {
|
|
181
|
+
runHelp(args);
|
|
263
182
|
}
|
|
264
183
|
|
|
265
184
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.16",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"LICENSE"
|
|
110
110
|
],
|
|
111
111
|
"scripts": {
|
|
112
|
-
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-adapter && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress",
|
|
112
|
+
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-adapter && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress && npm run test:ssr",
|
|
113
113
|
"test:compiler": "node test/compiler.test.js",
|
|
114
114
|
"test:sourcemap": "node test/sourcemap.test.js",
|
|
115
115
|
"test:pulse": "node test/pulse.test.js",
|
|
@@ -153,6 +153,7 @@
|
|
|
153
153
|
"test:http-edge-cases": "node test/http-edge-cases.test.js",
|
|
154
154
|
"test:integration-advanced": "node test/integration-advanced.test.js",
|
|
155
155
|
"test:websocket-stress": "node test/websocket-stress.test.js",
|
|
156
|
+
"test:ssr": "node test/ssr.test.js",
|
|
156
157
|
"build:netlify": "node scripts/build-netlify.js",
|
|
157
158
|
"version": "node scripts/sync-version.js",
|
|
158
159
|
"docs": "node cli/index.js dev docs"
|
package/runtime/async.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { pulse, effect, batch, onCleanup } from './pulse.js';
|
|
10
|
+
import { getSSRAsyncContext, registerAsync, getCachedAsync, hasCachedAsync } from './ssr-async.js';
|
|
10
11
|
|
|
11
12
|
// ============================================================================
|
|
12
13
|
// Versioned Async - Centralized Race Condition Handling
|
|
@@ -328,6 +329,44 @@ export function useAsync(asyncFn, options = {}) {
|
|
|
328
329
|
retryDelay = 1000
|
|
329
330
|
} = options;
|
|
330
331
|
|
|
332
|
+
// SSR MODE: Check for cached data or register async operation
|
|
333
|
+
const ssrCtx = getSSRAsyncContext();
|
|
334
|
+
if (ssrCtx) {
|
|
335
|
+
// Check if we already have cached data (second render pass)
|
|
336
|
+
if (hasCachedAsync(asyncFn)) {
|
|
337
|
+
const cachedData = getCachedAsync(asyncFn);
|
|
338
|
+
return {
|
|
339
|
+
data: pulse(cachedData),
|
|
340
|
+
error: pulse(null),
|
|
341
|
+
loading: pulse(false),
|
|
342
|
+
status: pulse('success'),
|
|
343
|
+
execute: () => Promise.resolve(cachedData),
|
|
344
|
+
reset: () => {},
|
|
345
|
+
abort: () => {}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// First render pass: register async operation for collection
|
|
350
|
+
if (immediate) {
|
|
351
|
+
const promise = asyncFn().catch(err => {
|
|
352
|
+
// Store error for SSR error handling
|
|
353
|
+
return null;
|
|
354
|
+
});
|
|
355
|
+
registerAsync(asyncFn, promise);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Return loading state for first pass
|
|
359
|
+
return {
|
|
360
|
+
data: pulse(initialData),
|
|
361
|
+
error: pulse(null),
|
|
362
|
+
loading: pulse(true),
|
|
363
|
+
status: pulse('loading'),
|
|
364
|
+
execute: () => Promise.resolve(initialData),
|
|
365
|
+
reset: () => {},
|
|
366
|
+
abort: () => {}
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
331
370
|
const data = pulse(initialData);
|
|
332
371
|
const error = pulse(null);
|
|
333
372
|
const loading = pulse(false);
|
package/runtime/dom-element.js
CHANGED
|
@@ -10,6 +10,16 @@ import { loggers } from './logger.js';
|
|
|
10
10
|
import { safeSetAttribute } from './utils.js';
|
|
11
11
|
import { getAdapter } from './dom-adapter.js';
|
|
12
12
|
import { parseSelector } from './dom-selector.js';
|
|
13
|
+
import {
|
|
14
|
+
isHydratingMode,
|
|
15
|
+
getHydrationContext,
|
|
16
|
+
getCurrentNode,
|
|
17
|
+
advanceCursor,
|
|
18
|
+
enterChild,
|
|
19
|
+
exitChild,
|
|
20
|
+
registerListener,
|
|
21
|
+
warnMismatch
|
|
22
|
+
} from './ssr-hydrator.js';
|
|
13
23
|
|
|
14
24
|
const log = loggers.dom;
|
|
15
25
|
|
|
@@ -214,6 +224,62 @@ function applyRoleRequirements(element, role, attrs, dom) {
|
|
|
214
224
|
export function el(selector, ...children) {
|
|
215
225
|
const dom = getAdapter();
|
|
216
226
|
const config = parseSelector(selector);
|
|
227
|
+
|
|
228
|
+
// HYDRATION MODE: Reuse existing DOM element
|
|
229
|
+
if (isHydratingMode()) {
|
|
230
|
+
const ctx = getHydrationContext();
|
|
231
|
+
const existing = getCurrentNode(ctx);
|
|
232
|
+
|
|
233
|
+
// Verify element matches
|
|
234
|
+
if (existing && existing.nodeType === 1) {
|
|
235
|
+
const tag = existing.tagName?.toLowerCase();
|
|
236
|
+
if (tag !== config.tag) {
|
|
237
|
+
warnMismatch(ctx, `<${config.tag}>`, existing);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Process children to attach event handlers from attributes
|
|
241
|
+
const [attrs, childContent] = separateAttrsAndChildren(children);
|
|
242
|
+
|
|
243
|
+
if (attrs) {
|
|
244
|
+
// Attach event handlers to existing element
|
|
245
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
246
|
+
if (key.startsWith('on') && typeof value === 'function') {
|
|
247
|
+
const event = key.slice(2).toLowerCase();
|
|
248
|
+
registerListener(ctx, existing, event, value);
|
|
249
|
+
}
|
|
250
|
+
// Handle reactive attributes
|
|
251
|
+
else if (typeof value === 'function' && !key.startsWith('on')) {
|
|
252
|
+
effect(() => {
|
|
253
|
+
const result = value();
|
|
254
|
+
if (key === 'class' || key === 'className') {
|
|
255
|
+
existing.className = result || '';
|
|
256
|
+
} else if (key === 'style' && typeof result === 'string') {
|
|
257
|
+
existing.style.cssText = result;
|
|
258
|
+
} else if (result != null) {
|
|
259
|
+
existing.setAttribute(key, result);
|
|
260
|
+
} else {
|
|
261
|
+
existing.removeAttribute(key);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Enter child scope and process children
|
|
269
|
+
enterChild(ctx, existing);
|
|
270
|
+
for (const child of childContent) {
|
|
271
|
+
hydrateChild(existing, child, ctx);
|
|
272
|
+
}
|
|
273
|
+
exitChild(ctx, existing);
|
|
274
|
+
|
|
275
|
+
return existing;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// No matching element found, warn and fall through to create
|
|
279
|
+
warnMismatch(ctx, `<${config.tag}>`, existing);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// NORMAL MODE: Create new element
|
|
217
283
|
const element = dom.createElement(config.tag);
|
|
218
284
|
|
|
219
285
|
if (config.id) {
|
|
@@ -240,6 +306,47 @@ export function el(selector, ...children) {
|
|
|
240
306
|
return element;
|
|
241
307
|
}
|
|
242
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Separate attributes object from children in el() arguments
|
|
311
|
+
* @private
|
|
312
|
+
*/
|
|
313
|
+
function separateAttrsAndChildren(children) {
|
|
314
|
+
if (children.length === 0) {
|
|
315
|
+
return [null, []];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const first = children[0];
|
|
319
|
+
if (first && typeof first === 'object' && !Array.isArray(first) &&
|
|
320
|
+
!(first instanceof Node) && !(first.nodeType)) {
|
|
321
|
+
return [first, children.slice(1)];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return [null, children];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Hydrate a child element (attach listeners without creating DOM)
|
|
329
|
+
* @private
|
|
330
|
+
*/
|
|
331
|
+
function hydrateChild(parent, child, ctx) {
|
|
332
|
+
if (child == null || child === false) return;
|
|
333
|
+
|
|
334
|
+
if (typeof child === 'string' || typeof child === 'number') {
|
|
335
|
+
// Text content - just advance cursor
|
|
336
|
+
advanceCursor(ctx);
|
|
337
|
+
} else if (typeof child === 'function') {
|
|
338
|
+
// Reactive child - set up effect but skip initial DOM creation
|
|
339
|
+
effect(() => {
|
|
340
|
+
child(); // Execute to track dependencies, but don't modify DOM on first run in hydration
|
|
341
|
+
});
|
|
342
|
+
} else if (Array.isArray(child)) {
|
|
343
|
+
for (const c of child) {
|
|
344
|
+
hydrateChild(parent, c, ctx);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Node children are handled by recursive el() calls
|
|
348
|
+
}
|
|
349
|
+
|
|
243
350
|
/**
|
|
244
351
|
* Append a child to an element, handling various types
|
|
245
352
|
*
|
package/runtime/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export * from './a11y.js';
|
|
|
12
12
|
export * from './context.js';
|
|
13
13
|
export * from './websocket.js';
|
|
14
14
|
export * from './graphql.js';
|
|
15
|
+
export * from './ssr.js';
|
|
15
16
|
|
|
16
17
|
export { default as PulseCore } from './pulse.js';
|
|
17
18
|
export { default as PulseDOM } from './dom.js';
|
|
@@ -23,3 +24,4 @@ export { default as PulseA11y } from './a11y.js';
|
|
|
23
24
|
export { default as PulseContext } from './context.js';
|
|
24
25
|
export { default as PulseWebSocket } from './websocket.js';
|
|
25
26
|
export { default as PulseGraphQL } from './graphql.js';
|
|
27
|
+
export { default as PulseSSR } from './ssr.js';
|
package/runtime/pulse.js
CHANGED
|
@@ -23,6 +23,35 @@ import { Errors } from './errors.js';
|
|
|
23
23
|
|
|
24
24
|
const log = loggers.pulse;
|
|
25
25
|
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// SSR MODE FLAG
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* SSR mode flag - when true, effects run once without setting up subscriptions.
|
|
32
|
+
* This is set by the SSR module during server-side rendering.
|
|
33
|
+
* @type {boolean}
|
|
34
|
+
*/
|
|
35
|
+
let ssrModeEnabled = false;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if SSR mode is enabled.
|
|
39
|
+
* In SSR mode, effects run once but don't subscribe to changes.
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isSSRMode() {
|
|
43
|
+
return ssrModeEnabled;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set SSR mode (used internally by ssr.js).
|
|
48
|
+
* @param {boolean} enabled - Whether to enable SSR mode
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export function setSSRMode(enabled) {
|
|
52
|
+
ssrModeEnabled = enabled;
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
// =============================================================================
|
|
27
56
|
// REACTIVE DEPENDENCY TRACKING ALGORITHM
|
|
28
57
|
// =============================================================================
|
|
@@ -885,6 +914,17 @@ export function effect(fn, options = {}) {
|
|
|
885
914
|
const { id: customId, onError } = options;
|
|
886
915
|
const effectId = customId || `effect_${++effectIdCounter}`;
|
|
887
916
|
|
|
917
|
+
// SSR MODE: Run effect once without subscriptions
|
|
918
|
+
if (ssrModeEnabled) {
|
|
919
|
+
try {
|
|
920
|
+
fn();
|
|
921
|
+
} catch (e) {
|
|
922
|
+
log.warn(`SSR effect error (${effectId}):`, e.message);
|
|
923
|
+
}
|
|
924
|
+
// Return noop cleanup function
|
|
925
|
+
return () => {};
|
|
926
|
+
}
|
|
927
|
+
|
|
888
928
|
// Capture module ID at creation time for HMR tracking
|
|
889
929
|
const moduleId = activeContext.currentModuleId;
|
|
890
930
|
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse SSR Async Context - Async operation collection for SSR
|
|
3
|
+
*
|
|
4
|
+
* Collects and manages async operations during server-side rendering.
|
|
5
|
+
* Enables data prefetching before HTML generation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// SSR Async Context
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Context for collecting async operations during SSR.
|
|
14
|
+
* Tracks pending promises and caches resolved data for re-renders.
|
|
15
|
+
*/
|
|
16
|
+
export class SSRAsyncContext {
|
|
17
|
+
constructor() {
|
|
18
|
+
/** @type {Array<{key: any, promise: Promise}>} */
|
|
19
|
+
this.pending = [];
|
|
20
|
+
|
|
21
|
+
/** @type {Map<any, any>} */
|
|
22
|
+
this.resolved = new Map();
|
|
23
|
+
|
|
24
|
+
/** @type {Map<any, Error>} */
|
|
25
|
+
this.errors = new Map();
|
|
26
|
+
|
|
27
|
+
/** @type {boolean} */
|
|
28
|
+
this.collecting = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register an async operation for collection.
|
|
33
|
+
* @param {any} key - Unique key for this operation (usually the async function)
|
|
34
|
+
* @param {Promise} promise - The promise to track
|
|
35
|
+
*/
|
|
36
|
+
register(key, promise) {
|
|
37
|
+
if (!this.collecting) return;
|
|
38
|
+
|
|
39
|
+
// Wrap promise to capture result
|
|
40
|
+
const tracked = promise
|
|
41
|
+
.then(data => {
|
|
42
|
+
this.resolved.set(key, data);
|
|
43
|
+
return data;
|
|
44
|
+
})
|
|
45
|
+
.catch(error => {
|
|
46
|
+
this.errors.set(key, error);
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
this.pending.push({ key, promise: tracked });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if a result is already cached.
|
|
55
|
+
* @param {any} key - Operation key
|
|
56
|
+
* @returns {boolean} True if result is cached
|
|
57
|
+
*/
|
|
58
|
+
has(key) {
|
|
59
|
+
return this.resolved.has(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get cached result for a key.
|
|
64
|
+
* @param {any} key - Operation key
|
|
65
|
+
* @returns {any} Cached result or undefined
|
|
66
|
+
*/
|
|
67
|
+
get(key) {
|
|
68
|
+
return this.resolved.get(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get error for a key (if the operation failed).
|
|
73
|
+
* @param {any} key - Operation key
|
|
74
|
+
* @returns {Error|undefined} Error or undefined
|
|
75
|
+
*/
|
|
76
|
+
getError(key) {
|
|
77
|
+
return this.errors.get(key);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wait for all pending async operations to complete.
|
|
82
|
+
* @param {number} [timeout=5000] - Maximum wait time in ms
|
|
83
|
+
* @returns {Promise<void>}
|
|
84
|
+
* @throws {Error} If timeout is exceeded
|
|
85
|
+
*/
|
|
86
|
+
async waitAll(timeout = 5000) {
|
|
87
|
+
if (this.pending.length === 0) return;
|
|
88
|
+
|
|
89
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
reject(new Error(`[Pulse SSR] Async operations timed out after ${timeout}ms`));
|
|
92
|
+
}, timeout);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Wait for all promises, catching individual errors
|
|
96
|
+
const allSettled = Promise.all(
|
|
97
|
+
this.pending.map(p => p.promise.catch(() => null))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await Promise.race([allSettled, timeoutPromise]);
|
|
101
|
+
|
|
102
|
+
// Stop collecting after wait
|
|
103
|
+
this.collecting = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get the number of pending operations.
|
|
108
|
+
* @returns {number}
|
|
109
|
+
*/
|
|
110
|
+
get pendingCount() {
|
|
111
|
+
return this.pending.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the number of resolved operations.
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
get resolvedCount() {
|
|
119
|
+
return this.resolved.size;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get the number of failed operations.
|
|
124
|
+
* @returns {number}
|
|
125
|
+
*/
|
|
126
|
+
get errorCount() {
|
|
127
|
+
return this.errors.size;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get all resolved data as a plain object.
|
|
132
|
+
* @returns {Object} Map of key → value
|
|
133
|
+
*/
|
|
134
|
+
getAllResolved() {
|
|
135
|
+
const result = {};
|
|
136
|
+
for (const [key, value] of this.resolved) {
|
|
137
|
+
// Use string key if function, otherwise try to serialize
|
|
138
|
+
const keyStr = typeof key === 'function' ? key.name || 'anonymous' : String(key);
|
|
139
|
+
result[keyStr] = value;
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reset the context for a new render pass.
|
|
146
|
+
*/
|
|
147
|
+
reset() {
|
|
148
|
+
this.pending = [];
|
|
149
|
+
this.resolved.clear();
|
|
150
|
+
this.errors.clear();
|
|
151
|
+
this.collecting = true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Global SSR Async Context
|
|
157
|
+
// ============================================================================
|
|
158
|
+
|
|
159
|
+
/** @type {SSRAsyncContext|null} */
|
|
160
|
+
let ssrAsyncContext = null;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get the current SSR async context.
|
|
164
|
+
* Returns null if not in SSR mode.
|
|
165
|
+
* @returns {SSRAsyncContext|null}
|
|
166
|
+
*/
|
|
167
|
+
export function getSSRAsyncContext() {
|
|
168
|
+
return ssrAsyncContext;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Set the SSR async context.
|
|
173
|
+
* @param {SSRAsyncContext|null} ctx - Context to set, or null to clear
|
|
174
|
+
*/
|
|
175
|
+
export function setSSRAsyncContext(ctx) {
|
|
176
|
+
ssrAsyncContext = ctx;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if currently in SSR async collection mode.
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
export function isCollectingAsync() {
|
|
184
|
+
return ssrAsyncContext !== null && ssrAsyncContext.collecting;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Register an async operation in the current SSR context.
|
|
189
|
+
* No-op if not in SSR mode.
|
|
190
|
+
* @param {any} key - Unique key for this operation
|
|
191
|
+
* @param {Promise} promise - The promise to track
|
|
192
|
+
*/
|
|
193
|
+
export function registerAsync(key, promise) {
|
|
194
|
+
if (ssrAsyncContext) {
|
|
195
|
+
ssrAsyncContext.register(key, promise);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get cached async result from current SSR context.
|
|
201
|
+
* @param {any} key - Operation key
|
|
202
|
+
* @returns {any} Cached result or undefined
|
|
203
|
+
*/
|
|
204
|
+
export function getCachedAsync(key) {
|
|
205
|
+
return ssrAsyncContext?.get(key);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if an async result is cached in current SSR context.
|
|
210
|
+
* @param {any} key - Operation key
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
export function hasCachedAsync(key) {
|
|
214
|
+
return ssrAsyncContext?.has(key) ?? false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ============================================================================
|
|
218
|
+
// Exports
|
|
219
|
+
// ============================================================================
|
|
220
|
+
|
|
221
|
+
export default {
|
|
222
|
+
SSRAsyncContext,
|
|
223
|
+
getSSRAsyncContext,
|
|
224
|
+
setSSRAsyncContext,
|
|
225
|
+
isCollectingAsync,
|
|
226
|
+
registerAsync,
|
|
227
|
+
getCachedAsync,
|
|
228
|
+
hasCachedAsync
|
|
229
|
+
};
|