@jxsuite/compiler 0.28.0 → 0.28.2
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/dist/cli.js +123 -215
- package/dist/compiler.js +77 -170
- package/package.json +4 -4
- package/src/compiler.ts +1 -6
- package/src/site/image-optimizer.ts +2 -3
- package/src/site/image-transform.ts +31 -21
- package/src/site/site-build.ts +22 -49
- package/src/site/site-loader.ts +2 -10
- package/src/targets/compile-server.ts +8 -58
- package/src/targets/compile-image-endpoint.ts +0 -138
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@apidevtools/json-schema-ref-parser": "^15.3.5",
|
|
45
|
-
"@jxsuite/parser": "^0.28.
|
|
46
|
-
"@jxsuite/runtime": "^0.28.
|
|
47
|
-
"@jxsuite/schema": "^0.28.
|
|
45
|
+
"@jxsuite/parser": "^0.28.2",
|
|
46
|
+
"@jxsuite/runtime": "^0.28.2",
|
|
47
|
+
"@jxsuite/schema": "^0.28.2",
|
|
48
48
|
"sharp": "^0.34.5"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
package/src/compiler.ts
CHANGED
|
@@ -22,11 +22,7 @@ import {
|
|
|
22
22
|
DEFAULT_REACTIVITY_SRC,
|
|
23
23
|
DEFAULT_LIT_HTML_SRC,
|
|
24
24
|
} from "./shared.ts";
|
|
25
|
-
import {
|
|
26
|
-
compileServer,
|
|
27
|
-
compileSiteServer,
|
|
28
|
-
compilePagesFunctions,
|
|
29
|
-
} from "./targets/compile-server.ts";
|
|
25
|
+
import { compileServer, compileSiteServer } from "./targets/compile-server.ts";
|
|
30
26
|
|
|
31
27
|
import {
|
|
32
28
|
compileElement,
|
|
@@ -42,7 +38,6 @@ export {
|
|
|
42
38
|
isDynamic,
|
|
43
39
|
compileServer,
|
|
44
40
|
compileSiteServer,
|
|
45
|
-
compilePagesFunctions,
|
|
46
41
|
compileElement,
|
|
47
42
|
compileElementPage,
|
|
48
43
|
compileClient,
|
|
@@ -31,11 +31,10 @@ export interface ImageConfig {
|
|
|
31
31
|
sizes: string;
|
|
32
32
|
lazyLoad: boolean;
|
|
33
33
|
service?: "build" | "cloudflare";
|
|
34
|
-
binding?: string;
|
|
35
34
|
/**
|
|
36
35
|
* Hostnames whose remote (https) images are routed through the optimization pipeline. Cloudflare
|
|
37
|
-
* service only —
|
|
38
|
-
*
|
|
36
|
+
* service only — allowlisted remote sources get /cdn-cgi/image transform srcsets (the zone must
|
|
37
|
+
* permit resizing from the remote origin); everything else is left untouched.
|
|
39
38
|
*/
|
|
40
39
|
remoteDomains?: string[];
|
|
41
40
|
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
10
|
import { resolve, extname, basename } from "node:path";
|
|
12
11
|
import {
|
|
13
12
|
processImage,
|
|
@@ -49,20 +48,35 @@ async function resolveCfMeta(absoluteSrc: string, metaCache: ImageMetaCache) {
|
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
/**
|
|
52
|
-
* Build a srcset of `/
|
|
53
|
-
* original image width.
|
|
51
|
+
* Build a srcset of Cloudflare `/cdn-cgi/image/` transform-via-URL entries for the configured
|
|
52
|
+
* widths that fit within the original image width. `format=auto` lets Cloudflare negotiate
|
|
53
|
+
* AVIF/WebP per browser; `fit=scale-down` guards against upscaling. Requires Image Transformations
|
|
54
|
+
* to be enabled on the serving zone (does not work on *.pages.dev).
|
|
54
55
|
*
|
|
55
|
-
* @param {string} src -
|
|
56
|
-
* @param {
|
|
56
|
+
* @param {string} src - Site-relative src (e.g. "/images/hero.png") or allowlisted https URL
|
|
57
|
+
* @param {ImageConfig} config
|
|
57
58
|
* @param {number} originalWidth
|
|
58
|
-
* @param {string} hash - 8-char content hash
|
|
59
|
+
* @param {string | null} hash - 8-char content hash for cache busting (null for remote sources,
|
|
60
|
+
* whose bytes aren't available at build time)
|
|
59
61
|
* @returns {string}
|
|
60
62
|
*/
|
|
61
|
-
function buildCloudflareSrcset(
|
|
62
|
-
|
|
63
|
+
function buildCloudflareSrcset(
|
|
64
|
+
src: string,
|
|
65
|
+
config: ImageConfig,
|
|
66
|
+
originalWidth: number,
|
|
67
|
+
hash: string | null,
|
|
68
|
+
) {
|
|
69
|
+
const quality = config.quality?.webp ?? 80;
|
|
70
|
+
const separator = src.startsWith("/") ? "" : "/";
|
|
71
|
+
const suffix = hash ? `?v=${hash}` : "";
|
|
72
|
+
return config.widths
|
|
63
73
|
.filter((w) => w <= originalWidth)
|
|
64
74
|
.sort((a, b) => a - b)
|
|
65
|
-
.map(
|
|
75
|
+
.map(
|
|
76
|
+
(w) =>
|
|
77
|
+
`/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
|
|
78
|
+
`${separator}${src}${suffix} ${w}w`,
|
|
79
|
+
)
|
|
66
80
|
.join(", ");
|
|
67
81
|
}
|
|
68
82
|
|
|
@@ -113,8 +127,9 @@ function shouldSkip(src: string) {
|
|
|
113
127
|
/**
|
|
114
128
|
* Check if a remote src is eligible for cloudflare-service optimization: an https URL whose
|
|
115
129
|
* hostname is in the project's `images.remoteDomains` allowlist. Content data (e.g. CSV columns)
|
|
116
|
-
* routinely references externally hosted images — allowlisted hosts flow through the /
|
|
117
|
-
*
|
|
130
|
+
* routinely references externally hosted images — allowlisted hosts flow through the /cdn-cgi/image
|
|
131
|
+
* transform URL like local assets; everything else passes through untouched. The zone must permit
|
|
132
|
+
* resizing from the remote origin (Images → Transformations → Sources).
|
|
118
133
|
*
|
|
119
134
|
* @param {string} src
|
|
120
135
|
* @param {ImageConfig} config
|
|
@@ -134,11 +149,6 @@ function isAllowedRemote(src: string, config: ImageConfig) {
|
|
|
134
149
|
}
|
|
135
150
|
}
|
|
136
151
|
|
|
137
|
-
/** 8-char cache-busting hash for a remote URL (original bytes aren't available at build time). */
|
|
138
|
-
function urlHash(src: string) {
|
|
139
|
-
return createHash("sha256").update(src).digest("hex").slice(0, 8);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
152
|
/**
|
|
143
153
|
* Resolve a src path to an absolute filesystem path.
|
|
144
154
|
*
|
|
@@ -258,11 +268,11 @@ async function transformImgNode(
|
|
|
258
268
|
|
|
259
269
|
if (remote) {
|
|
260
270
|
// Original dimensions are unknown without fetching — emit every configured width and let
|
|
261
|
-
//
|
|
262
|
-
srcset = buildCloudflareSrcset(src, config
|
|
271
|
+
// fit=scale-down avoid upscaling past the source size.
|
|
272
|
+
srcset = buildCloudflareSrcset(src, config, Infinity, null);
|
|
263
273
|
} else if (config.service === "cloudflare") {
|
|
264
274
|
const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
|
|
265
|
-
srcset = buildCloudflareSrcset(src, config
|
|
275
|
+
srcset = buildCloudflareSrcset(src, config, meta.width, meta.hash);
|
|
266
276
|
original = meta;
|
|
267
277
|
} else {
|
|
268
278
|
let manifest = imageRefs.get(absoluteSrc as string);
|
|
@@ -344,10 +354,10 @@ async function transformInnerHtmlImages(
|
|
|
344
354
|
let original: { width: number; height: number } | undefined;
|
|
345
355
|
|
|
346
356
|
if (remote) {
|
|
347
|
-
srcset = buildCloudflareSrcset(src, config
|
|
357
|
+
srcset = buildCloudflareSrcset(src, config, Infinity, null);
|
|
348
358
|
} else if (config.service === "cloudflare") {
|
|
349
359
|
const meta = await resolveCfMeta(absoluteSrc as string, metaCache);
|
|
350
|
-
srcset = buildCloudflareSrcset(src, config
|
|
360
|
+
srcset = buildCloudflareSrcset(src, config, meta.width, meta.hash);
|
|
351
361
|
original = meta;
|
|
352
362
|
} else {
|
|
353
363
|
let manifest = imageRefs.get(absoluteSrc as string);
|
package/src/site/site-build.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
|
25
25
|
import { resolveLayout } from "./layout-resolver.ts";
|
|
26
26
|
import { mergeHead, renderHead } from "./head-merger.ts";
|
|
27
27
|
import { injectContext } from "./context-injection.ts";
|
|
28
|
-
import { compile, compileServer, compileSiteServer
|
|
28
|
+
import { compile, compileServer, compileSiteServer } from "../compiler.ts";
|
|
29
29
|
import { compileElement } from "../targets/compile-element.ts";
|
|
30
30
|
import {
|
|
31
31
|
buildInitialScope,
|
|
@@ -45,7 +45,6 @@ import { loadContentTypes, loadContentConfig, resolveContentTypeRefs } from "./c
|
|
|
45
45
|
import { resolvePrototypes } from "./prototype-resolver.ts";
|
|
46
46
|
import { transformImageNodes } from "./image-transform.ts";
|
|
47
47
|
import { loadCache, saveCache, getImageCacheDir } from "./image-cache.ts";
|
|
48
|
-
import { compilePagesImageFunction } from "../targets/compile-image-endpoint.ts";
|
|
49
48
|
import type { ImageConfig } from "./image-optimizer.ts";
|
|
50
49
|
import type { ImageMetaCache } from "./image-transform.ts";
|
|
51
50
|
import type {
|
|
@@ -205,11 +204,9 @@ export async function buildSite(
|
|
|
205
204
|
const imageMetaCache = cfImages ? new Map() : null;
|
|
206
205
|
if (cfImages) {
|
|
207
206
|
console.log(
|
|
208
|
-
`images.service is "cloudflare" —
|
|
209
|
-
`
|
|
210
|
-
|
|
211
|
-
? ` and an assets binding ("assets": { "binding": "ASSETS" }).`
|
|
212
|
-
: `.`),
|
|
207
|
+
`images.service is "cloudflare" — srcsets use /cdn-cgi/image transform URLs. ` +
|
|
208
|
+
`Ensure Image Transformations are enabled for your zone (Cloudflare dashboard → ` +
|
|
209
|
+
`Images → Transformations); these URLs do not work on *.pages.dev / *.workers.dev previews.`,
|
|
213
210
|
);
|
|
214
211
|
}
|
|
215
212
|
|
|
@@ -321,24 +318,29 @@ export async function buildSite(
|
|
|
321
318
|
if (!deduped.has(entry.exportName)) deduped.set(entry.exportName, entry);
|
|
322
319
|
}
|
|
323
320
|
|
|
324
|
-
|
|
325
|
-
|
|
321
|
+
// Cloudflare Pages uses advanced mode (_worker.js inside the build output) — the
|
|
322
|
+
// functions/ directory convention only works from the project root, not from dist/.
|
|
323
|
+
// A static-only Pages site needs no worker at all.
|
|
324
|
+
const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0;
|
|
325
|
+
const workerSource = skipWorker ? null : compileSiteServer([...deduped.values()], { adapter });
|
|
326
326
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
if (workerSource) {
|
|
328
|
+
const workerName = adapter === "cloudflare-pages" ? "_worker.js" : "worker.js";
|
|
329
|
+
writeFileSync(resolve(outDir, workerName), workerSource, "utf8");
|
|
330
|
+
fileCount++;
|
|
331
|
+
log(` Generated dist/${workerName} (${deduped.size} server function(s))`);
|
|
332
|
+
|
|
333
|
+
if (adapter === "cloudflare-pages") {
|
|
334
|
+
// Only invoke the worker for server routes; everything else stays static.
|
|
335
|
+
writeFileSync(
|
|
336
|
+
resolve(outDir, "_routes.json"),
|
|
337
|
+
JSON.stringify({ version: 1, include: ["/_jx/*"], exclude: [] }, null, 2) + "\n",
|
|
338
|
+
"utf8",
|
|
331
339
|
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
for (const [filePath, source] of functions) {
|
|
335
|
-
const fullPath = resolve(outDir, filePath);
|
|
336
|
-
mkdirSync(resolve(fullPath, ".."), { recursive: true });
|
|
337
|
-
writeFileSync(fullPath, source, "utf8");
|
|
338
340
|
fileCount++;
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
// Copy server source files so
|
|
343
|
+
// Copy server source files into dist/components/ so worker imports resolve
|
|
342
344
|
const distComponentsDir = resolve(outDir, "components");
|
|
343
345
|
mkdirSync(distComponentsDir, { recursive: true });
|
|
344
346
|
for (const { src } of deduped.values()) {
|
|
@@ -348,35 +350,6 @@ export async function buildSite(
|
|
|
348
350
|
copyFileSync(srcFile, destFile);
|
|
349
351
|
}
|
|
350
352
|
}
|
|
351
|
-
|
|
352
|
-
if (functions.size > 0) {
|
|
353
|
-
log(` Generated ${functions.size} Pages function(s) in dist/functions/`);
|
|
354
|
-
}
|
|
355
|
-
} else {
|
|
356
|
-
const workerSource = compileSiteServer([...deduped.values()], {
|
|
357
|
-
adapter,
|
|
358
|
-
images:
|
|
359
|
-
cfImages && adapter === "cloudflare-workers"
|
|
360
|
-
? (projectConfig.images as ImageConfig)
|
|
361
|
-
: null,
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
if (workerSource) {
|
|
365
|
-
const workerPath = resolve(outDir, "worker.js");
|
|
366
|
-
writeFileSync(workerPath, workerSource, "utf8");
|
|
367
|
-
fileCount++;
|
|
368
|
-
log(` Generated dist/worker.js (${deduped.size} server function(s))`);
|
|
369
|
-
|
|
370
|
-
// Copy server source files into dist/components/ so worker imports resolve
|
|
371
|
-
const distComponentsDir = resolve(outDir, "components");
|
|
372
|
-
for (const { src } of deduped.values()) {
|
|
373
|
-
const srcFile = resolve(projectRoot, src.replace(/^\.\//, ""));
|
|
374
|
-
const destFile = resolve(distComponentsDir, src.replace(/^\.\/components\//, ""));
|
|
375
|
-
if (existsSync(srcFile)) {
|
|
376
|
-
copyFileSync(srcFile, destFile);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
353
|
}
|
|
381
354
|
}
|
|
382
355
|
|
package/src/site/site-loader.ts
CHANGED
|
@@ -35,7 +35,6 @@ const DEFAULTS = {
|
|
|
35
35
|
sizes: "(max-width: 768px) 100vw, 50vw",
|
|
36
36
|
lazyLoad: true,
|
|
37
37
|
service: "build",
|
|
38
|
-
binding: "IMAGES",
|
|
39
38
|
},
|
|
40
39
|
build: {
|
|
41
40
|
outDir: "./dist",
|
|
@@ -98,7 +97,8 @@ export function loadProjectConfig(projectRoot: string) {
|
|
|
98
97
|
);
|
|
99
98
|
}
|
|
100
99
|
|
|
101
|
-
// Validate image service
|
|
100
|
+
// Validate image service. The "cloudflare" service emits /cdn-cgi/image transform URLs,
|
|
101
|
+
// which work on any zone served through Cloudflare regardless of the build adapter.
|
|
102
102
|
const VALID_IMAGE_SERVICES = ["build", "cloudflare"];
|
|
103
103
|
if (!VALID_IMAGE_SERVICES.includes(config.images.service)) {
|
|
104
104
|
throw new Error(
|
|
@@ -106,14 +106,6 @@ export function loadProjectConfig(projectRoot: string) {
|
|
|
106
106
|
`Valid services: ${VALID_IMAGE_SERVICES.join(", ")}`,
|
|
107
107
|
);
|
|
108
108
|
}
|
|
109
|
-
const CF_ADAPTERS = ["cloudflare-workers", "cloudflare-pages"];
|
|
110
|
-
if (config.images.service === "cloudflare" && !CF_ADAPTERS.includes(config.build.adapter)) {
|
|
111
|
-
console.warn(
|
|
112
|
-
`images.service "cloudflare" requires a Cloudflare adapter ` +
|
|
113
|
-
`(${CF_ADAPTERS.join(", ")}) — falling back to "build"`,
|
|
114
|
-
);
|
|
115
|
-
config.images.service = "build";
|
|
116
|
-
}
|
|
117
109
|
|
|
118
110
|
return {
|
|
119
111
|
config,
|
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import $RefParser from "@apidevtools/json-schema-ref-parser";
|
|
9
9
|
import { collectServerEntries } from "../shared.ts";
|
|
10
|
-
import { buildImageHandlerSource, buildImageWorkerRoute } from "./compile-image-endpoint.ts";
|
|
11
10
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
12
|
-
import type { ImageConfig } from "../site/image-optimizer.ts";
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* Compile a Jx document to a Hono server handler file. The handler exposes each `timing: "server"`
|
|
@@ -63,9 +61,8 @@ export default app
|
|
|
63
61
|
* @param {{ exportName: string; src: string }[]} entries - Resolved server entries
|
|
64
62
|
* @param {object} [opts]
|
|
65
63
|
* @param {string} [opts.baseUrl] - Base path for server endpoints. Default is `'/_jx/server'`
|
|
66
|
-
* @param {string | null} [opts.adapter] - Deployment adapter: `"cloudflare"`,
|
|
67
|
-
*
|
|
68
|
-
* endpoint at `/_jx/image` (images.service: "cloudflare")
|
|
64
|
+
* @param {string | null} [opts.adapter] - Deployment adapter: `"cloudflare-workers"`,
|
|
65
|
+
* `"cloudflare-pages"`, `"node"`, `"bun"`
|
|
69
66
|
* @returns {string | null} Complete worker/server source string, or null
|
|
70
67
|
*/
|
|
71
68
|
export function compileSiteServer(
|
|
@@ -73,10 +70,9 @@ export function compileSiteServer(
|
|
|
73
70
|
opts: {
|
|
74
71
|
baseUrl?: string;
|
|
75
72
|
adapter?: string | null;
|
|
76
|
-
images?: ImageConfig | null;
|
|
77
73
|
} = {},
|
|
78
74
|
) {
|
|
79
|
-
const { baseUrl = "/_jx/server", adapter = null
|
|
75
|
+
const { baseUrl = "/_jx/server", adapter = null } = opts;
|
|
80
76
|
if (entries.length === 0 && !adapter) return null;
|
|
81
77
|
|
|
82
78
|
const imports = entries
|
|
@@ -85,21 +81,19 @@ export function compileSiteServer(
|
|
|
85
81
|
|
|
86
82
|
const routes = entries.map(({ exportName }) => buildRoute(exportName, baseUrl)).join("\n");
|
|
87
83
|
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
const imageRoute = images ? buildImageWorkerRoute() : "";
|
|
91
|
-
|
|
84
|
+
// Workers static assets and Pages advanced mode (_worker.js) both route every request
|
|
85
|
+
// through the worker — unmatched paths must fall through to the ASSETS binding.
|
|
92
86
|
const adapterBlock =
|
|
93
|
-
adapter === "cloudflare-workers"
|
|
87
|
+
adapter === "cloudflare-workers" || adapter === "cloudflare-pages"
|
|
94
88
|
? "\napp.all('*', (c) => c.env.ASSETS.fetch(c.req.raw))\n"
|
|
95
89
|
: "\n";
|
|
96
90
|
|
|
97
91
|
return `// Generated by @jxsuite/compiler — do not edit manually
|
|
98
92
|
import { Hono } from 'hono'
|
|
99
93
|
${imports}
|
|
100
|
-
|
|
94
|
+
|
|
101
95
|
const app = new Hono()
|
|
102
|
-
${routes}
|
|
96
|
+
${routes}
|
|
103
97
|
${adapterBlock}
|
|
104
98
|
export default app
|
|
105
99
|
`;
|
|
@@ -117,47 +111,3 @@ app.post('${baseUrl}/${exportName}', async (c) => {
|
|
|
117
111
|
}
|
|
118
112
|
})`;
|
|
119
113
|
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Generate Cloudflare Pages Functions from pre-collected server entries. Returns a Map of relative
|
|
123
|
-
* file paths (under `functions/`) to source strings.
|
|
124
|
-
*
|
|
125
|
-
* @param {{ exportName: string; src: string }[]} entries - Resolved server entries
|
|
126
|
-
* @param {object} [opts]
|
|
127
|
-
* @param {string} [opts.baseUrl] - Base path prefix. Default is `'/_jx/server'`
|
|
128
|
-
* @returns {Map<string, string>} Map of output path → source (e.g.
|
|
129
|
-
* `"functions/_jx/server/sendEmail.js"` → source)
|
|
130
|
-
*/
|
|
131
|
-
export function compilePagesFunctions(
|
|
132
|
-
entries: { exportName: string; src: string }[],
|
|
133
|
-
opts: {
|
|
134
|
-
baseUrl?: string;
|
|
135
|
-
} = {},
|
|
136
|
-
) {
|
|
137
|
-
const { baseUrl = "/_jx/server" } = opts;
|
|
138
|
-
const files = new Map();
|
|
139
|
-
|
|
140
|
-
for (const { exportName, src } of entries) {
|
|
141
|
-
const relativeSrc = src.startsWith("./") ? src : `./${src}`;
|
|
142
|
-
const funcPath = `functions${baseUrl}/${exportName}.js`;
|
|
143
|
-
const depth = funcPath.split("/").length - 1;
|
|
144
|
-
const importPath = "../".repeat(depth) + relativeSrc.replace(/^\.\//, "");
|
|
145
|
-
|
|
146
|
-
const source = `// Generated by @jxsuite/compiler — do not edit manually
|
|
147
|
-
import { ${exportName} } from '${importPath}'
|
|
148
|
-
|
|
149
|
-
export async function onRequestPost(context) {
|
|
150
|
-
const args = await context.request.json().catch(() => ({}))
|
|
151
|
-
try {
|
|
152
|
-
const result = await ${exportName}(args, context.env)
|
|
153
|
-
return Response.json(result)
|
|
154
|
-
} catch (e) {
|
|
155
|
-
return Response.json({ ok: false, error: e?.message ?? 'Server error' }, { status: 500 })
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
`;
|
|
159
|
-
files.set(funcPath, source);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return files;
|
|
163
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compile-image-endpoint.js — Cloudflare Images binding endpoint codegen
|
|
3
|
-
*
|
|
4
|
-
* Generates the `/_jx/image` runtime image-transformation handler used when `images.service:
|
|
5
|
-
* "cloudflare"` is set in project.json. The handler fetches the original static asset via the
|
|
6
|
-
* ASSETS binding and pipes it through the Cloudflare Images binding, negotiating the output format
|
|
7
|
-
* from the request's Accept header.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import type { ImageConfig } from "../site/image-optimizer.ts";
|
|
11
|
-
|
|
12
|
-
const MIME: Record<string, string> = {
|
|
13
|
-
avif: "image/avif",
|
|
14
|
-
webp: "image/webp",
|
|
15
|
-
jpeg: "image/jpeg",
|
|
16
|
-
png: "image/png",
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Generate the shared `jxHandleImage(request, env, waitUntil)` handler source. Widths, format
|
|
21
|
-
* preference order (avif first, mirroring the build-time pipeline), per-format quality, and the
|
|
22
|
-
* Images binding name are baked in at codegen time.
|
|
23
|
-
*
|
|
24
|
-
* @param {ImageConfig} images - The project's images config
|
|
25
|
-
* @returns {string} JavaScript source defining `jxHandleImage`
|
|
26
|
-
*/
|
|
27
|
-
export function buildImageHandlerSource(images: ImageConfig) {
|
|
28
|
-
const binding = images.binding ?? "IMAGES";
|
|
29
|
-
const formats = [...(images.formats ?? [])]
|
|
30
|
-
.filter((f) => f in MIME)
|
|
31
|
-
.sort((a, b) => Number(b === "avif") - Number(a === "avif"));
|
|
32
|
-
const tuples = formats.map((f) => [
|
|
33
|
-
MIME[f],
|
|
34
|
-
images.quality?.[f as keyof ImageConfig["quality"]] ?? 80,
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
return `const JX_IMG_WIDTHS = new Set(${JSON.stringify(images.widths ?? [])})
|
|
38
|
-
const JX_IMG_FORMATS = ${JSON.stringify(tuples)} // [mime, quality] in preference order
|
|
39
|
-
const JX_IMG_BINDING = ${JSON.stringify(binding)}
|
|
40
|
-
const JX_IMG_REMOTE_DOMAINS = new Set(${JSON.stringify(images.remoteDomains ?? [])})
|
|
41
|
-
|
|
42
|
-
async function jxHandleImage(request, env, waitUntil) {
|
|
43
|
-
const url = new URL(request.url)
|
|
44
|
-
const src = url.searchParams.get('src') ?? ''
|
|
45
|
-
const w = Number(url.searchParams.get('w') ?? '')
|
|
46
|
-
|
|
47
|
-
// Same-origin asset paths, or https sources from allowlisted remote domains —
|
|
48
|
-
// never act as an open proxy.
|
|
49
|
-
let remoteSrc = null
|
|
50
|
-
if (src.startsWith('https://')) {
|
|
51
|
-
try {
|
|
52
|
-
const remote = new URL(src)
|
|
53
|
-
if (JX_IMG_REMOTE_DOMAINS.has(remote.hostname)) remoteSrc = remote
|
|
54
|
-
} catch {}
|
|
55
|
-
if (!remoteSrc) return new Response('Invalid src', { status: 400 })
|
|
56
|
-
} else if (!src.startsWith('/') || src.startsWith('//') || src.includes('..') ||
|
|
57
|
-
src.includes(':') || src.includes('\\\\') || src.includes('?') || src.includes('#')) {
|
|
58
|
-
return new Response('Invalid src', { status: 400 })
|
|
59
|
-
}
|
|
60
|
-
if (!JX_IMG_WIDTHS.has(w)) return new Response('Invalid width', { status: 400 })
|
|
61
|
-
|
|
62
|
-
const accept = request.headers.get('accept') ?? ''
|
|
63
|
-
let target = null
|
|
64
|
-
for (const [mime, quality] of JX_IMG_FORMATS) {
|
|
65
|
-
if (accept.includes(mime)) { target = { mime, quality }; break }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const v = url.searchParams.get('v') ?? ''
|
|
69
|
-
const cacheKey = new Request(url.origin + '/_jx/image?src=' + encodeURIComponent(src) +
|
|
70
|
-
'&w=' + w + '&f=' + (target ? target.mime : 'orig') + '&v=' + encodeURIComponent(v))
|
|
71
|
-
const cache = caches.default
|
|
72
|
-
const hit = await cache.match(cacheKey)
|
|
73
|
-
if (hit) {
|
|
74
|
-
const out = new Response(hit.body, hit)
|
|
75
|
-
out.headers.set('Vary', 'Accept')
|
|
76
|
-
return out
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const fetchSource = () =>
|
|
80
|
-
remoteSrc
|
|
81
|
-
? fetch(remoteSrc.href, { redirect: 'follow' })
|
|
82
|
-
: env.ASSETS.fetch(new Request(new URL(src, url.origin)))
|
|
83
|
-
|
|
84
|
-
const asset = await fetchSource()
|
|
85
|
-
if (!asset.ok || !asset.body) return new Response('Not found', { status: 404 })
|
|
86
|
-
|
|
87
|
-
let response
|
|
88
|
-
if (target && env[JX_IMG_BINDING]) {
|
|
89
|
-
try {
|
|
90
|
-
const result = await env[JX_IMG_BINDING]
|
|
91
|
-
.input(asset.body)
|
|
92
|
-
.transform({ width: w, fit: 'scale-down' })
|
|
93
|
-
.output({ format: target.mime, quality: target.quality })
|
|
94
|
-
response = result.response()
|
|
95
|
-
} catch (e) {
|
|
96
|
-
// Transform failed — serve the original source untouched.
|
|
97
|
-
const retry = await fetchSource()
|
|
98
|
-
response = new Response(retry.body, retry)
|
|
99
|
-
}
|
|
100
|
-
} else {
|
|
101
|
-
response = new Response(asset.body, asset)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
response = new Response(response.body, response)
|
|
105
|
-
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable')
|
|
106
|
-
if (waitUntil) waitUntil(cache.put(cacheKey, response.clone()))
|
|
107
|
-
response.headers.set('Vary', 'Accept')
|
|
108
|
-
return response
|
|
109
|
-
}`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Generate the Hono route registering `jxHandleImage` at `/_jx/image`. Must be injected before the
|
|
114
|
-
* cloudflare-workers `app.all('*')` ASSETS catch-all.
|
|
115
|
-
*
|
|
116
|
-
* @returns {string}
|
|
117
|
-
*/
|
|
118
|
-
export function buildImageWorkerRoute() {
|
|
119
|
-
return `
|
|
120
|
-
app.get('/_jx/image', (c) => jxHandleImage(c.req.raw, c.env, (p) => c.executionCtx?.waitUntil?.(p)))`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Generate a complete Cloudflare Pages Function (`functions/_jx/image.js`) wrapping `jxHandleImage`
|
|
125
|
-
* in an `onRequestGet` handler.
|
|
126
|
-
*
|
|
127
|
-
* @param {ImageConfig} images - The project's images config
|
|
128
|
-
* @returns {string}
|
|
129
|
-
*/
|
|
130
|
-
export function compilePagesImageFunction(images: ImageConfig) {
|
|
131
|
-
return `// Generated by @jxsuite/compiler — do not edit manually
|
|
132
|
-
${buildImageHandlerSource(images)}
|
|
133
|
-
|
|
134
|
-
export async function onRequestGet(context) {
|
|
135
|
-
return jxHandleImage(context.request, context.env, (p) => context.waitUntil(p))
|
|
136
|
-
}
|
|
137
|
-
`;
|
|
138
|
-
}
|