@chr33s/solarflare 0.0.2 → 0.0.3
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/package.json +20 -11
- package/readme.md +1 -1
- package/src/build.bundle-client.ts +11 -1
- package/src/build.hmr-entry.ts +2 -1
- package/src/build.scan.ts +7 -0
- package/src/build.ts +0 -0
- package/src/client.ts +26 -43
- package/src/diff-dom-streaming.ts +24 -0
- package/src/early-flush.ts +1 -6
- package/src/hmr.ts +14 -1
- package/src/manifest.runtime.ts +5 -0
- package/src/manifest.ts +2 -0
- package/src/server.ts +6 -1
- package/src/worker.config.ts +1 -13
- package/src/worker.ts +22 -36
- package/src/critical-css.ts +0 -103
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chr33s/solarflare",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/chr33s/solarflare"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"solarflare": "src/build.ts"
|
|
11
|
+
},
|
|
6
12
|
"files": [
|
|
7
13
|
"src",
|
|
8
14
|
"tsconfig.json",
|
|
@@ -20,33 +26,36 @@
|
|
|
20
26
|
},
|
|
21
27
|
"scripts": {
|
|
22
28
|
"check": "oxfmt --check && oxlint --type-aware --type-check",
|
|
29
|
+
"examples:clean": "rm -rvf ./examples/*/.wrangler ./examples/*/.shopify ./examples/*/dist ./examples/*/node_modules",
|
|
30
|
+
"examples:install": "for dir in basic dsd minimal node shopify-app; do npm install --prefix ./examples/${dir} --silent; done && bun install --cwd=./examples/bun && cd examples/deno && deno install",
|
|
23
31
|
"fix": "oxlint --type-aware --type-check --fix && oxfmt --write",
|
|
24
32
|
"test": "WRANGLER_LOG=error; node --test src/*.test.ts"
|
|
25
33
|
},
|
|
26
34
|
"dependencies": {
|
|
27
|
-
"@preact/signals": "2.8.
|
|
35
|
+
"@preact/signals": "2.8.1",
|
|
28
36
|
"lightningcss": "1.31.1",
|
|
29
37
|
"picomatch": "4.0.3",
|
|
30
38
|
"preact": "11.0.0-beta.1",
|
|
31
39
|
"preact-custom-element": "4.6.0",
|
|
32
|
-
"preact-render-to-string": "6.6.
|
|
33
|
-
"rolldown": "1.0.0-rc.
|
|
40
|
+
"preact-render-to-string": "6.6.6",
|
|
41
|
+
"rolldown": "1.0.0-rc.5",
|
|
34
42
|
"turbo-stream": "3.1.0"
|
|
35
43
|
},
|
|
36
44
|
"devDependencies": {
|
|
37
|
-
"@preact/signals-debug": "1.4.
|
|
38
|
-
"@types/node": "25.
|
|
39
|
-
"oxfmt": "0.
|
|
40
|
-
"oxlint": "1.
|
|
41
|
-
"oxlint-tsgolint": "0.
|
|
45
|
+
"@preact/signals-debug": "1.4.2",
|
|
46
|
+
"@types/node": "25.3.0",
|
|
47
|
+
"oxfmt": "0.35.0",
|
|
48
|
+
"oxlint": "1.50.0",
|
|
49
|
+
"oxlint-tsgolint": "0.14.2",
|
|
42
50
|
"playwright": "1.58.2",
|
|
43
51
|
"typescript": "5.9.3",
|
|
44
|
-
"wrangler": "4.
|
|
52
|
+
"wrangler": "4.68.0"
|
|
45
53
|
},
|
|
46
54
|
"optionalDependencies": {
|
|
47
55
|
"ts-morph": "27.0.2"
|
|
48
56
|
},
|
|
49
57
|
"engines": {
|
|
58
|
+
"bun": ">=1.3.0",
|
|
50
59
|
"node": ">=24.12.0"
|
|
51
60
|
}
|
|
52
61
|
}
|
package/readme.md
CHANGED
|
@@ -132,7 +132,6 @@ import { Deferred } from "@chr33s/solarflare/client";
|
|
|
132
132
|
<!-- performance -->
|
|
133
133
|
<meta name="sf:preconnect" content="https://cdn.example.com" />
|
|
134
134
|
<meta name="sf:early-flush" content="true" />
|
|
135
|
-
<meta name="sf:critical-css" content="true" />
|
|
136
135
|
<meta name="sf:cache-max-age" content="300" />
|
|
137
136
|
<meta name="sf:cache-swr" content="3600" />
|
|
138
137
|
<meta name="sf:prefetch" content="/about, /faq, /blog/*" />
|
|
@@ -160,6 +159,7 @@ export default define(MyComponent, { shadow: true });
|
|
|
160
159
|
- [Basic](examples/basic/readme.md) — Layouts, dynamic routes, API, components
|
|
161
160
|
- [Bun](examples/bun/readme.md) — Bun runtime example
|
|
162
161
|
- [Deno](examples/deno/readme.md) — Deno runtime example
|
|
162
|
+
- [DSD](examples/dsd/readme.md) — Declarative Shadow DOM example
|
|
163
163
|
- [Minimal](examples/minimal/readme.md) — Single route
|
|
164
164
|
- [Node](examples/node/readme.md) — Using `srvx` instead of Workers
|
|
165
165
|
- [Shopify App](examples/shopify-app/readme.md) — Shopify app starter
|
|
@@ -78,7 +78,7 @@ async function getComponentMeta(program: ts.Program, appDir: string, file: strin
|
|
|
78
78
|
const contentHash = hash(content);
|
|
79
79
|
const chunk = getChunkName(file, contentHash);
|
|
80
80
|
|
|
81
|
-
return { file, tag: parsed.tag, props, parsed, chunk, hash: contentHash };
|
|
81
|
+
return { file, tag: parsed.tag, props, parsed, chunk, hash: contentHash, shadow: false };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export async function buildClient(options: BuildClientOptions) {
|
|
@@ -215,6 +215,10 @@ export async function buildClient(options: BuildClientOptions) {
|
|
|
215
215
|
const componentPath = join(appDir, meta.file);
|
|
216
216
|
const componentCssImports = await scanner.extractAllCssImports(componentPath);
|
|
217
217
|
|
|
218
|
+
if (await scanner.detectShadowOption(componentPath)) {
|
|
219
|
+
meta.shadow = true;
|
|
220
|
+
}
|
|
221
|
+
|
|
218
222
|
const componentDir = meta.file.split("/").slice(0, -1).join("/");
|
|
219
223
|
const componentCssOutputPaths = await resolveCssOutputs(componentCssImports, componentDir);
|
|
220
224
|
|
|
@@ -365,12 +369,18 @@ export async function buildClient(options: BuildClientOptions) {
|
|
|
365
369
|
chunks: {},
|
|
366
370
|
tags: {},
|
|
367
371
|
styles: {},
|
|
372
|
+
shadow: {},
|
|
368
373
|
devScripts: args.production ? undefined : ["/assets/console-forward.js"],
|
|
369
374
|
};
|
|
370
375
|
|
|
371
376
|
for (const meta of metas) {
|
|
372
377
|
manifest.chunks[meta.parsed.pattern] = `/assets/${meta.chunk}`;
|
|
373
378
|
manifest.tags[meta.tag] = `/assets/${meta.chunk}`;
|
|
379
|
+
|
|
380
|
+
const filePath = join(appDir, meta.file);
|
|
381
|
+
if (await scanner.detectShadowOption(filePath)) {
|
|
382
|
+
manifest.shadow![meta.tag] = true;
|
|
383
|
+
}
|
|
374
384
|
}
|
|
375
385
|
|
|
376
386
|
for (const meta of metas) {
|
package/src/build.hmr-entry.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface ComponentMeta {
|
|
|
10
10
|
tag: string;
|
|
11
11
|
props: string[];
|
|
12
12
|
chunk: string;
|
|
13
|
+
shadow?: boolean;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function buildDebugImports(args: HmrEntryArgs) {
|
|
@@ -47,7 +48,7 @@ function buildEntryInit(meta: ComponentMeta, cssFiles: string[]) {
|
|
|
47
48
|
return /* tsx */ `
|
|
48
49
|
initHmrEntry({
|
|
49
50
|
tag: '${meta.tag}',
|
|
50
|
-
props: ${JSON.stringify(meta.props)}
|
|
51
|
+
props: ${JSON.stringify(meta.props)},${meta.shadow ? `\n shadow: true,` : ""}
|
|
51
52
|
routesManifest,
|
|
52
53
|
BaseComponent,
|
|
53
54
|
hmr,
|
package/src/build.scan.ts
CHANGED
|
@@ -167,6 +167,12 @@ export function createScanner(ctx: BuildScanContext) {
|
|
|
167
167
|
return allCss;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** Detects `define(_, { shadow: true })` in a client component file. */
|
|
171
|
+
async function detectShadowOption(filePath: string) {
|
|
172
|
+
const content = await readFile(filePath, "utf-8");
|
|
173
|
+
return /define\s*\([^)]*\{[^}]*shadow\s*:\s*true/.test(content);
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
return {
|
|
171
177
|
scanFiles,
|
|
172
178
|
getPackageImports,
|
|
@@ -178,5 +184,6 @@ export function createScanner(ctx: BuildScanContext) {
|
|
|
178
184
|
extractComponentImports,
|
|
179
185
|
resolveImportPath,
|
|
180
186
|
extractAllCssImports,
|
|
187
|
+
detectShadowOption,
|
|
181
188
|
};
|
|
182
189
|
}
|
package/src/build.ts
CHANGED
|
File without changes
|
package/src/client.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type FunctionComponent } from "preact";
|
|
2
|
-
import register from "preact-custom-element";
|
|
3
2
|
import { parsePath } from "./paths.ts";
|
|
4
3
|
import { hydrateStore, initHydrationCoordinator } from "./hydration.ts";
|
|
5
4
|
import { installHeadHoisting, createHeadContext, setHeadContext } from "./head.ts";
|
|
@@ -34,6 +33,14 @@ export function registerInlineStyles(tag: string, styles: InlineStyleEntry[]) {
|
|
|
34
33
|
if (!styles.length) return;
|
|
35
34
|
if (!supportsConstructableStylesheets() || typeof document === "undefined") return;
|
|
36
35
|
|
|
36
|
+
// If DSD already provided inline <style> in the shadow root, migrate it to
|
|
37
|
+
// adoptedStyleSheets so StylesheetManager.update() can reach it during HMR.
|
|
38
|
+
const el = document.querySelector(tag);
|
|
39
|
+
const shadowStyle = el?.shadowRoot?.querySelector("style");
|
|
40
|
+
if (shadowStyle) {
|
|
41
|
+
shadowStyle.remove();
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
for (const style of styles) {
|
|
38
45
|
const preloaded = getPreloadedStylesheet(style.id);
|
|
39
46
|
if (!preloaded) {
|
|
@@ -42,10 +49,18 @@ export function registerInlineStyles(tag: string, styles: InlineStyleEntry[]) {
|
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
const sheets = stylesheets.getForConsumer(tag);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
const shadowRoot = el?.shadowRoot;
|
|
53
|
+
if (shadowRoot) {
|
|
54
|
+
shadowRoot.adoptedStyleSheets = [
|
|
55
|
+
...shadowRoot.adoptedStyleSheets.filter((s) => !sheets.includes(s)),
|
|
56
|
+
...sheets,
|
|
57
|
+
];
|
|
58
|
+
} else {
|
|
59
|
+
document.adoptedStyleSheets = [
|
|
60
|
+
...document.adoptedStyleSheets.filter((s) => !sheets.includes(s)),
|
|
61
|
+
...sheets,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
/** Tag metadata from file path. */
|
|
@@ -145,46 +160,14 @@ export interface DefineOptions {
|
|
|
145
160
|
validate?: boolean;
|
|
146
161
|
}
|
|
147
162
|
|
|
148
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Registers a Preact component as a web component.
|
|
165
|
+
* When used inside the solarflare build pipeline, `initHmrEntry` handles
|
|
166
|
+
* actual registration — `define()` just returns the component.
|
|
167
|
+
*/
|
|
149
168
|
export function define<P extends Record<string, any>>(
|
|
150
169
|
Component: FunctionComponent<P>,
|
|
151
|
-
|
|
170
|
+
_options?: DefineOptions,
|
|
152
171
|
) {
|
|
153
|
-
// Only register custom elements in the browser
|
|
154
|
-
if (typeof window !== "undefined" && typeof HTMLElement !== "undefined") {
|
|
155
|
-
const propNames = options?.observedAttributes ?? [];
|
|
156
|
-
const filePath = import.meta.path ?? import.meta.url ?? "";
|
|
157
|
-
const meta = parseTagMeta(filePath);
|
|
158
|
-
const tag = options?.tag ?? meta.tag;
|
|
159
|
-
const shadow = options?.shadow ?? false;
|
|
160
|
-
const shouldValidate = options?.validate ?? import.meta.env?.DEV ?? false;
|
|
161
|
-
|
|
162
|
-
if (customElements.get(tag)) {
|
|
163
|
-
console.warn(`[solarflare] Custom element "${tag}" is already registered, skipping`);
|
|
164
|
-
return Component;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (shouldValidate) {
|
|
168
|
-
const validation = validateTag({ ...meta, tag });
|
|
169
|
-
|
|
170
|
-
for (const warning of validation.warnings) {
|
|
171
|
-
console.warn(`[solarflare] ${warning}`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
for (const error of validation.errors) {
|
|
175
|
-
console.error(`[solarflare] ${error}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (!validation.valid) {
|
|
179
|
-
console.error(
|
|
180
|
-
`[solarflare] Tag validation failed for "${filePath}", component may not work correctly`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Register the component as a custom element
|
|
186
|
-
register(Component, tag, propNames, { shadow });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
172
|
return Component;
|
|
190
173
|
}
|
|
@@ -64,6 +64,17 @@ async function updateNode(oldNode: Node, newNode: Node, walker: Walker) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
if (oldNode.nodeType === ELEMENT_TYPE) {
|
|
67
|
+
// Treat DSD custom elements as atomic: the old DOM has a real shadow root
|
|
68
|
+
// while the new HTML has a <template shadowrootmode> child — these can't
|
|
69
|
+
// be diffed structurally, so replace the entire element.
|
|
70
|
+
if (hasShadowRoot(oldNode as Element) && hasDsdTemplate(newNode as Element)) {
|
|
71
|
+
return walker[APPLY_TRANSITION](() => {
|
|
72
|
+
if (oldNode.parentNode) {
|
|
73
|
+
oldNode.parentNode.replaceChild(newNode.cloneNode(true), oldNode);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
67
78
|
await setChildNodes(oldNode, newNode, walker);
|
|
68
79
|
|
|
69
80
|
walker[APPLY_TRANSITION](() => {
|
|
@@ -86,6 +97,19 @@ async function updateNode(oldNode: Node, newNode: Node, walker: Walker) {
|
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/** Checks if an element has a live shadow root (from DSD or imperative). */
|
|
101
|
+
function hasShadowRoot(el: Element) {
|
|
102
|
+
return !!(el as HTMLElement).shadowRoot;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Checks if an element contains a `<template shadowrootmode>` child. */
|
|
106
|
+
function hasDsdTemplate(el: Element) {
|
|
107
|
+
for (let c = el.firstElementChild; c; c = c.nextElementSibling) {
|
|
108
|
+
if (c.nodeName === "TEMPLATE" && c.hasAttribute("shadowrootmode")) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
/**
|
|
90
114
|
* Utility that will update one list of attributes to match another.
|
|
91
115
|
*/
|
package/src/early-flush.ts
CHANGED
|
@@ -39,7 +39,6 @@ export function generateStaticShell(options: {
|
|
|
39
39
|
export function createEarlyFlushStream(
|
|
40
40
|
shell: StreamingShell,
|
|
41
41
|
options: {
|
|
42
|
-
criticalCss?: string;
|
|
43
42
|
preloadHints?: string;
|
|
44
43
|
contentStream: ReadableStream<Uint8Array>;
|
|
45
44
|
headTags: string;
|
|
@@ -52,11 +51,7 @@ export function createEarlyFlushStream(
|
|
|
52
51
|
|
|
53
52
|
return new ReadableStream<Uint8Array>({
|
|
54
53
|
async start(controller) {
|
|
55
|
-
const shellStart = [
|
|
56
|
-
shell.preHead,
|
|
57
|
-
options.preloadHints || "",
|
|
58
|
-
options.criticalCss ? /* html */ `<style>${options.criticalCss}</style>` : "",
|
|
59
|
-
].join("");
|
|
54
|
+
const shellStart = [shell.preHead, options.preloadHints || ""].join("");
|
|
60
55
|
|
|
61
56
|
controller.enqueue(encoder.encode(shellStart));
|
|
62
57
|
|
package/src/hmr.ts
CHANGED
|
@@ -437,6 +437,7 @@ export function onHMREvent(
|
|
|
437
437
|
interface HmrEntryOptions {
|
|
438
438
|
tag: string;
|
|
439
439
|
props: string[];
|
|
440
|
+
shadow?: boolean;
|
|
440
441
|
routesManifest: RoutesManifest;
|
|
441
442
|
BaseComponent: FunctionComponent<any>;
|
|
442
443
|
hmr: HmrApi;
|
|
@@ -642,6 +643,18 @@ function createHmrEntryComponent(options: HmrEntryOptions) {
|
|
|
642
643
|
export function initHmrEntry(options: HmrEntryOptions) {
|
|
643
644
|
const Component = createHmrEntryComponent(options);
|
|
644
645
|
if (!customElements.get(options.tag)) {
|
|
645
|
-
|
|
646
|
+
const shadow = options.shadow ?? false;
|
|
647
|
+
if (shadow) {
|
|
648
|
+
// When DSD has already created a shadow root, patch attachShadow to
|
|
649
|
+
// return the existing root so preact-custom-element won't throw.
|
|
650
|
+
const orig = HTMLElement.prototype.attachShadow.bind(HTMLElement.prototype);
|
|
651
|
+
HTMLElement.prototype.attachShadow = function (init: ShadowRootInit) {
|
|
652
|
+
return this.shadowRoot ?? orig.call(this, init);
|
|
653
|
+
};
|
|
654
|
+
register(Component, options.tag, options.props, { shadow });
|
|
655
|
+
HTMLElement.prototype.attachShadow = orig;
|
|
656
|
+
} else {
|
|
657
|
+
register(Component, options.tag, options.props, { shadow });
|
|
658
|
+
}
|
|
646
659
|
}
|
|
647
660
|
}
|
package/src/manifest.runtime.ts
CHANGED
|
@@ -19,6 +19,11 @@ export function getStylesheets(pattern: string) {
|
|
|
19
19
|
return manifest.styles[pattern] ?? [];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Gets whether a tag uses Shadow DOM (DSD). */
|
|
23
|
+
export function isShadowTag(tag: string) {
|
|
24
|
+
return manifest.shadow?.[tag] ?? false;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
/** Gets dev mode scripts from the chunk manifest. */
|
|
23
28
|
export function getDevScripts() {
|
|
24
29
|
return manifest.devScripts;
|
package/src/manifest.ts
CHANGED
package/src/server.ts
CHANGED
|
@@ -222,6 +222,7 @@ export function renderComponent(
|
|
|
222
222
|
Component: FunctionComponent<any>,
|
|
223
223
|
tag: string,
|
|
224
224
|
props: Record<string, unknown>,
|
|
225
|
+
options?: { shadow?: boolean },
|
|
225
226
|
) {
|
|
226
227
|
const attrs: Record<string, string> = {};
|
|
227
228
|
for (const [key, value] of Object.entries(props)) {
|
|
@@ -230,7 +231,11 @@ export function renderComponent(
|
|
|
230
231
|
attrs[key] = String(value);
|
|
231
232
|
}
|
|
232
233
|
}
|
|
233
|
-
|
|
234
|
+
const children = h(Component, props);
|
|
235
|
+
if (options?.shadow) {
|
|
236
|
+
return h(tag, attrs, h("template", { shadowrootmode: "open" }, children));
|
|
237
|
+
}
|
|
238
|
+
return h(tag, attrs, children);
|
|
234
239
|
}
|
|
235
240
|
|
|
236
241
|
/** Error page props interface. */
|
package/src/worker.config.ts
CHANGED
|
@@ -18,8 +18,7 @@ export interface WorkerMetaConfig {
|
|
|
18
18
|
cacheConfig?: RouteCacheConfig;
|
|
19
19
|
/** Enable early flush */
|
|
20
20
|
earlyFlush: boolean;
|
|
21
|
-
|
|
22
|
-
criticalCss: boolean;
|
|
21
|
+
|
|
23
22
|
/** URLs/patterns to prefetch */
|
|
24
23
|
prefetch: string[];
|
|
25
24
|
/** URLs/patterns to prerender */
|
|
@@ -35,7 +34,6 @@ const DEFAULTS: WorkerMetaConfig = {
|
|
|
35
34
|
lang: "en",
|
|
36
35
|
preconnectOrigins: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
|
37
36
|
earlyFlush: false,
|
|
38
|
-
criticalCss: false,
|
|
39
37
|
prefetch: [],
|
|
40
38
|
prerender: [],
|
|
41
39
|
speculationEagerness: "moderate",
|
|
@@ -80,11 +78,6 @@ export function parseMetaConfig(html: string) {
|
|
|
80
78
|
config.earlyFlush = earlyFlush === "true";
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
const criticalCss = matchMeta("sf:critical-css");
|
|
84
|
-
if (criticalCss) {
|
|
85
|
-
config.criticalCss = criticalCss === "true";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
81
|
const prefetch = matchMeta("sf:prefetch");
|
|
89
82
|
if (prefetch) {
|
|
90
83
|
config.prefetch = prefetch
|
|
@@ -138,7 +131,6 @@ export function workerConfigMeta(config: {
|
|
|
138
131
|
cacheMaxAge?: number;
|
|
139
132
|
cacheSwr?: number;
|
|
140
133
|
earlyFlush?: boolean;
|
|
141
|
-
criticalCss?: boolean;
|
|
142
134
|
prefetch?: string[];
|
|
143
135
|
prerender?: string[];
|
|
144
136
|
prefetchSelector?: string;
|
|
@@ -164,10 +156,6 @@ export function workerConfigMeta(config: {
|
|
|
164
156
|
meta.push({ name: "sf:early-flush", content: String(config.earlyFlush) });
|
|
165
157
|
}
|
|
166
158
|
|
|
167
|
-
if (config.criticalCss !== undefined) {
|
|
168
|
-
meta.push({ name: "sf:critical-css", content: String(config.criticalCss) });
|
|
169
|
-
}
|
|
170
|
-
|
|
171
159
|
if (config.prefetch?.length) {
|
|
172
160
|
meta.push({ name: "sf:prefetch", content: config.prefetch.join(",") });
|
|
173
161
|
}
|
package/src/worker.ts
CHANGED
|
@@ -10,12 +10,17 @@ import {
|
|
|
10
10
|
generateResourceHints,
|
|
11
11
|
type StreamingShell,
|
|
12
12
|
} from "./early-flush.ts";
|
|
13
|
-
import { extractCriticalCss, generateAsyncCssLoader } from "./critical-css.ts";
|
|
14
13
|
import { collectEarlyHints, generateEarlyHintsHeader } from "./early-hints.ts";
|
|
15
14
|
import { ResponseCache, withCache } from "./route-cache.ts";
|
|
16
15
|
import { parseMetaConfig } from "./worker.config.ts";
|
|
17
16
|
import { getHeadContext, type HeadTag } from "./head.ts";
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
typedModules,
|
|
19
|
+
getScriptPath,
|
|
20
|
+
getStylesheets,
|
|
21
|
+
getDevScripts,
|
|
22
|
+
isShadowTag,
|
|
23
|
+
} from "./manifest.runtime.ts";
|
|
19
24
|
import { findPairedModulePath } from "./paths.ts";
|
|
20
25
|
import { encode } from "turbo-stream";
|
|
21
26
|
|
|
@@ -38,8 +43,6 @@ function getStaticShell(lang: string) {
|
|
|
38
43
|
/** Worker optimization options. */
|
|
39
44
|
export interface WorkerOptimizations {
|
|
40
45
|
earlyFlush?: boolean;
|
|
41
|
-
criticalCss?: boolean;
|
|
42
|
-
readCss?: (path: string) => Promise<string>;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** Server data loader function type. */
|
|
@@ -79,11 +82,8 @@ interface RenderPlan {
|
|
|
79
82
|
status: number;
|
|
80
83
|
statusText?: string;
|
|
81
84
|
metaConfig: ReturnType<typeof parseMetaConfig>;
|
|
82
|
-
stylesheets: string[];
|
|
83
85
|
resourceHints: string;
|
|
84
86
|
useEarlyFlush: boolean;
|
|
85
|
-
useCriticalCss: boolean;
|
|
86
|
-
pathname: string;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
type MatchAndLoadResult =
|
|
@@ -296,6 +296,13 @@ async function matchAndLoad(request: Request, url: URL): Promise<MatchAndLoadRes
|
|
|
296
296
|
deferredPromises[key] = value;
|
|
297
297
|
} else {
|
|
298
298
|
immediateData[key] = value;
|
|
299
|
+
// Non-primitive values can't be serialized as HTML attributes on the
|
|
300
|
+
// custom element. Emit them as instantly-resolved deferred data islands
|
|
301
|
+
// so the client-side hydration mechanism picks them up.
|
|
302
|
+
const t = typeof value;
|
|
303
|
+
if (value != null && t !== "string" && t !== "number" && t !== "boolean") {
|
|
304
|
+
deferredPromises[key] = Promise.resolve(value);
|
|
305
|
+
}
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
308
|
|
|
@@ -308,7 +315,9 @@ async function matchAndLoad(request: Request, url: URL): Promise<MatchAndLoadRes
|
|
|
308
315
|
const clientMod = await typedModules.client[clientPath]();
|
|
309
316
|
const Component = clientMod.default as FunctionComponent<any>;
|
|
310
317
|
|
|
311
|
-
let content = server.renderComponent(Component, route.tag, props
|
|
318
|
+
let content = server.renderComponent(Component, route.tag, props, {
|
|
319
|
+
shadow: isShadowTag(route.tag),
|
|
320
|
+
});
|
|
312
321
|
|
|
313
322
|
const layouts = server.findLayouts(route.path, typedModules);
|
|
314
323
|
if (layouts.length > 0) {
|
|
@@ -363,7 +372,6 @@ async function renderStream(
|
|
|
363
372
|
});
|
|
364
373
|
|
|
365
374
|
const useEarlyFlush = envOptimizations.earlyFlush ?? metaConfig.earlyFlush;
|
|
366
|
-
const useCriticalCss = envOptimizations.criticalCss ?? metaConfig.criticalCss;
|
|
367
375
|
|
|
368
376
|
const ssrStream = await server.renderToStream(content, {
|
|
369
377
|
params,
|
|
@@ -395,45 +403,23 @@ async function renderStream(
|
|
|
395
403
|
status: ssrStream.status ?? 200,
|
|
396
404
|
statusText: ssrStream.statusText,
|
|
397
405
|
metaConfig,
|
|
398
|
-
stylesheets,
|
|
399
406
|
resourceHints,
|
|
400
407
|
useEarlyFlush,
|
|
401
|
-
useCriticalCss,
|
|
402
|
-
pathname: route.parsedPattern.pathname,
|
|
403
408
|
};
|
|
404
409
|
}
|
|
405
410
|
|
|
406
|
-
async function applyPerfFeatures(plan: RenderPlan
|
|
407
|
-
const {
|
|
408
|
-
|
|
409
|
-
finalHeaders,
|
|
410
|
-
status,
|
|
411
|
-
statusText,
|
|
412
|
-
stylesheets,
|
|
413
|
-
resourceHints,
|
|
414
|
-
useEarlyFlush,
|
|
415
|
-
useCriticalCss,
|
|
416
|
-
pathname,
|
|
417
|
-
metaConfig,
|
|
418
|
-
} = plan;
|
|
411
|
+
async function applyPerfFeatures(plan: RenderPlan) {
|
|
412
|
+
const { ssrStream, finalHeaders, status, statusText, resourceHints, useEarlyFlush, metaConfig } =
|
|
413
|
+
plan;
|
|
419
414
|
|
|
420
415
|
if (useEarlyFlush) {
|
|
421
416
|
const staticShell = getStaticShell(metaConfig.lang);
|
|
422
417
|
|
|
423
|
-
let criticalCss = "";
|
|
424
|
-
if (useCriticalCss && envOptimizations.readCss) {
|
|
425
|
-
criticalCss = await extractCriticalCss(pathname, stylesheets, {
|
|
426
|
-
readCss: envOptimizations.readCss,
|
|
427
|
-
cache: true,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
|
|
431
418
|
const optimizedStream = createEarlyFlushStream(staticShell, {
|
|
432
|
-
criticalCss,
|
|
433
419
|
preloadHints: resourceHints,
|
|
434
420
|
contentStream: ssrStream,
|
|
435
421
|
headTags: "",
|
|
436
|
-
bodyTags:
|
|
422
|
+
bodyTags: "",
|
|
437
423
|
});
|
|
438
424
|
|
|
439
425
|
return new Response(optimizedStream, {
|
|
@@ -518,7 +504,7 @@ async function worker(request: Request, env?: WorkerEnv) {
|
|
|
518
504
|
|
|
519
505
|
const render = async () => {
|
|
520
506
|
const plan = await renderStream(context, headers, envOptimizations);
|
|
521
|
-
return applyPerfFeatures(plan
|
|
507
|
+
return applyPerfFeatures(plan);
|
|
522
508
|
};
|
|
523
509
|
|
|
524
510
|
if (context.metaConfig.cacheConfig) {
|
package/src/critical-css.ts
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { escapeJsonForHtml } from "./serialize.ts";
|
|
3
|
-
|
|
4
|
-
/** Critical CSS cache entry. */
|
|
5
|
-
interface CriticalCssEntry {
|
|
6
|
-
css: string;
|
|
7
|
-
hash: string;
|
|
8
|
-
timestamp: number;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** In-memory cache for critical CSS (per route). */
|
|
12
|
-
const criticalCssCache = new Map<string, CriticalCssEntry>();
|
|
13
|
-
|
|
14
|
-
/** Max age for cached critical CSS (1 hour). */
|
|
15
|
-
const CACHE_MAX_AGE = 60 * 60 * 1000;
|
|
16
|
-
|
|
17
|
-
/** Extracts critical CSS for a route */
|
|
18
|
-
export async function extractCriticalCss(
|
|
19
|
-
routePattern: string,
|
|
20
|
-
cssFiles: string[],
|
|
21
|
-
options: {
|
|
22
|
-
readCss: (path: string) => Promise<string>;
|
|
23
|
-
maxSize?: number;
|
|
24
|
-
cache?: boolean;
|
|
25
|
-
},
|
|
26
|
-
) {
|
|
27
|
-
const cacheKey = routePattern;
|
|
28
|
-
const maxSize = options.maxSize ?? 14 * 1024;
|
|
29
|
-
|
|
30
|
-
if (options.cache !== false) {
|
|
31
|
-
const cached = criticalCssCache.get(cacheKey);
|
|
32
|
-
if (cached && Date.now() - cached.timestamp < CACHE_MAX_AGE) {
|
|
33
|
-
return cached.css;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const cssContents: string[] = [];
|
|
38
|
-
let totalSize = 0;
|
|
39
|
-
|
|
40
|
-
for (const file of cssFiles) {
|
|
41
|
-
try {
|
|
42
|
-
const content = await options.readCss(file);
|
|
43
|
-
const minified = minifyCss(content);
|
|
44
|
-
|
|
45
|
-
if (totalSize + minified.length > maxSize) {
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
cssContents.push(minified);
|
|
50
|
-
totalSize += minified.length;
|
|
51
|
-
} catch {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const criticalCss = cssContents.join("\n");
|
|
55
|
-
const hash = createHash("md5").update(criticalCss).digest("hex").slice(0, 8);
|
|
56
|
-
|
|
57
|
-
if (options.cache !== false) {
|
|
58
|
-
criticalCssCache.set(cacheKey, {
|
|
59
|
-
css: criticalCss,
|
|
60
|
-
hash,
|
|
61
|
-
timestamp: Date.now(),
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return criticalCss;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Simple CSS minification for critical CSS */
|
|
69
|
-
function minifyCss(css: string) {
|
|
70
|
-
return css
|
|
71
|
-
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
72
|
-
.replace(/\s+/g, " ")
|
|
73
|
-
.replace(/\s*([{}: ;,])\s*/g, "$1")
|
|
74
|
-
.trim();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Generates a noscript fallback for CSS loading. */
|
|
78
|
-
export function generateCssFallback(stylesheets: string[]) {
|
|
79
|
-
const links = stylesheets
|
|
80
|
-
.map((href) => /* html */ `<link rel="stylesheet" href="${href}">`)
|
|
81
|
-
.join("");
|
|
82
|
-
return /* html */ `<noscript>${links}</noscript>`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Generates async CSS loading script without blocking render on non-critical CSS */
|
|
86
|
-
export function generateAsyncCssLoader(stylesheets: string[]) {
|
|
87
|
-
if (stylesheets.length === 0) return "";
|
|
88
|
-
|
|
89
|
-
const hrefs = escapeJsonForHtml(stylesheets);
|
|
90
|
-
|
|
91
|
-
return /* html */ `
|
|
92
|
-
<script>
|
|
93
|
-
(function() {
|
|
94
|
-
var ss=${hrefs};
|
|
95
|
-
ss.forEach(function(h){
|
|
96
|
-
var l=document.createElement('link');
|
|
97
|
-
l.rel='stylesheet';l.href=h;
|
|
98
|
-
document.head.appendChild(l);
|
|
99
|
-
});
|
|
100
|
-
})();
|
|
101
|
-
</script>
|
|
102
|
-
`;
|
|
103
|
-
}
|