@jxsuite/compiler 5.1.2 → 6.0.1
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 +218 -346
- package/dist/compiler.js +130 -258
- package/package.json +11 -10
- package/src/cli.ts +12 -14
- package/src/compiler.ts +45 -23
- package/src/shadow.ts +9 -8
- package/src/shared.ts +693 -329
- package/src/site/asset-mounts.ts +22 -12
- package/src/site/bundler.ts +38 -12
- package/src/site/client-runtime.ts +31 -17
- package/src/site/csp.ts +20 -11
- package/src/site/db-push.ts +38 -24
- package/src/site/dev-command.ts +3 -3
- package/src/site/format-host.ts +56 -45
- package/src/site/headers-emitter.ts +7 -4
- package/src/site/i18n.ts +20 -19
- package/src/site/image-cache.ts +16 -3
- package/src/site/image-optimizer.ts +19 -5
- package/src/site/image-transform.ts +56 -60
- package/src/site/img-loading.ts +11 -5
- package/src/site/layout-resolver.ts +7 -6
- package/src/site/link-relations.ts +10 -6
- package/src/site/locale-negotiation.ts +1 -1
- package/src/site/pages-discovery.ts +65 -47
- package/src/site/preview-server.ts +22 -19
- package/src/site/project-sections.ts +16 -3
- package/src/site/prototype-resolver.ts +94 -42
- package/src/site/schema-command.ts +26 -13
- package/src/site/service-worker.ts +2 -2
- package/src/site/site-build.ts +421 -279
- package/src/site/site-loader.ts +6 -3
- package/src/site/static-host.ts +3 -3
- package/src/site/validate-command.ts +64 -34
- package/src/site/well-known.ts +57 -12
- package/src/targets/compile-class.ts +17 -5
- package/src/targets/compile-client.ts +156 -111
- package/src/targets/compile-element.ts +227 -198
- package/src/targets/compile-server.ts +11 -5
- package/src/targets/compile-static.ts +57 -63
- package/src/types.ts +5 -6
package/src/site/asset-mounts.ts
CHANGED
|
@@ -20,8 +20,16 @@ import {
|
|
|
20
20
|
} from "@jxsuite/schema/asset-paths";
|
|
21
21
|
import type { AssetMount } from "@jxsuite/schema/asset-paths";
|
|
22
22
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
23
|
+
import { isFunction, isNonNullObject } from "@jxsuite/schema/guards";
|
|
24
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
23
25
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
24
26
|
|
|
27
|
+
/** What {@link copyMountedAssets} reports: files written, and URLs that resolved to nothing. */
|
|
28
|
+
export interface MountedAssetCopy {
|
|
29
|
+
copied: number;
|
|
30
|
+
missing: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
/**
|
|
26
34
|
* Gather every asset mount contributed by the project's extensions.
|
|
27
35
|
*
|
|
@@ -45,31 +53,33 @@ export async function loadAssetMounts(
|
|
|
45
53
|
|
|
46
54
|
for (const entry of registry.assetProviders()) {
|
|
47
55
|
const sectionKey = entry.project?.key;
|
|
48
|
-
const sectionValue = sectionKey
|
|
49
|
-
? (projectConfig as unknown as Record<string, unknown>)[sectionKey]
|
|
50
|
-
: undefined;
|
|
56
|
+
const sectionValue = sectionKey ? projectConfig[sectionKey] : undefined;
|
|
51
57
|
if (
|
|
52
58
|
sectionKey &&
|
|
53
59
|
(sectionValue == null ||
|
|
54
|
-
(
|
|
60
|
+
(isNonNullObject(sectionValue) && Object.keys(sectionValue).length === 0))
|
|
55
61
|
) {
|
|
56
62
|
continue;
|
|
57
63
|
}
|
|
58
64
|
try {
|
|
59
|
-
const declared =
|
|
65
|
+
const declared = await entry.call("assets", sectionValue ?? null, {
|
|
60
66
|
projectConfig,
|
|
61
67
|
root: projectRoot,
|
|
62
|
-
})
|
|
68
|
+
});
|
|
69
|
+
/* The answer is extension code's, so each entry is checked before it is used: a hole, or a
|
|
70
|
+
mount missing its URL prefix or its directory, declares nothing. */
|
|
63
71
|
for (const mount of declared ?? []) {
|
|
64
|
-
if (!mount
|
|
72
|
+
if (!isNonNullObject(mount) || !mount.urlPrefix || !mount.dir) {
|
|
65
73
|
continue;
|
|
66
74
|
}
|
|
67
75
|
const normalized: AssetMount = {
|
|
68
76
|
dir: mount.dir,
|
|
69
77
|
urlPrefix: normalizeAssetPrefix(mount.urlPrefix),
|
|
70
|
-
// What the owner says the mount may serve travels with it (a notes folder's `exclude`).
|
|
71
|
-
...(typeof mount.filter === "function" && { filter: mount.filter }),
|
|
72
78
|
};
|
|
79
|
+
// What the owner says the mount may serve travels with it (a notes folder's `exclude`).
|
|
80
|
+
if (isFunction(mount.filter)) {
|
|
81
|
+
normalized.filter = mount.filter;
|
|
82
|
+
}
|
|
73
83
|
const prior = byPrefix.get(normalized.urlPrefix);
|
|
74
84
|
if (prior) {
|
|
75
85
|
if (prior.dir !== normalized.dir) {
|
|
@@ -84,7 +94,7 @@ export async function loadAssetMounts(
|
|
|
84
94
|
mounts.push(normalized);
|
|
85
95
|
}
|
|
86
96
|
} catch (error) {
|
|
87
|
-
errors.push(`Error in ${entry.name}.assets: ${(error
|
|
97
|
+
errors.push(`Error in ${entry.name}.assets: ${errorMessage(error)}`);
|
|
88
98
|
}
|
|
89
99
|
}
|
|
90
100
|
|
|
@@ -113,13 +123,13 @@ export function collectAssetRefs(text: string, mounts: readonly AssetMount[], in
|
|
|
113
123
|
* @param {Iterable<string>} urls - Mounted URLs collected from the compiled output
|
|
114
124
|
* @param {readonly AssetMount[]} mounts
|
|
115
125
|
* @param {string} outDir - Absolute build output directory
|
|
116
|
-
* @returns {
|
|
126
|
+
* @returns {MountedAssetCopy} Files written, and URLs that resolved to nothing
|
|
117
127
|
*/
|
|
118
128
|
export function copyMountedAssets(
|
|
119
129
|
urls: Iterable<string>,
|
|
120
130
|
mounts: readonly AssetMount[],
|
|
121
131
|
outDir: string,
|
|
122
|
-
):
|
|
132
|
+
): MountedAssetCopy {
|
|
123
133
|
let copied = 0;
|
|
124
134
|
const missing: string[] = [];
|
|
125
135
|
|
package/src/site/bundler.ts
CHANGED
|
@@ -16,7 +16,6 @@ import { createRequire } from "node:module";
|
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { basename, join, resolve } from "node:path";
|
|
18
18
|
import { isNpmSpecifier, NPM_SPECIFIER_PREFIX } from "@jxsuite/schema/asset-paths";
|
|
19
|
-
import type { BunPlugin } from "bun";
|
|
20
19
|
|
|
21
20
|
/** Import specifiers left external in client bundles — provided by the page importmap. */
|
|
22
21
|
export const CLIENT_EXTERNALS = ["@vue/reactivity", "lit-html"];
|
|
@@ -111,16 +110,28 @@ export interface BundleOptions {
|
|
|
111
110
|
onFileLoaded?: (path: string) => void;
|
|
112
111
|
}
|
|
113
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The part of a plugin builder the hooks use, typed so that both Bun's `PluginBuilder` and
|
|
115
|
+
* esbuild's `PluginBuild` are assignable to it: a plugin written against it is a plugin for either
|
|
116
|
+
* backend.
|
|
117
|
+
*/
|
|
114
118
|
interface PluginBuild {
|
|
115
119
|
onResolve: (
|
|
116
120
|
options: { filter: RegExp },
|
|
117
|
-
callback: (args: { path: string; importer: string }) =>
|
|
121
|
+
callback: (args: { path: string; importer: string }) => ReturnType<BundleResolver["resolve"]>,
|
|
118
122
|
) => void;
|
|
119
|
-
|
|
123
|
+
/** An `undefined` result hands the file back to the backend's own loader. */
|
|
124
|
+
onLoad: (options: { filter: RegExp }, callback: (args: { path: string }) => undefined) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A plugin in the one shape both `Bun.build` and `esbuild.build` accept. */
|
|
128
|
+
interface BundlePlugin {
|
|
129
|
+
name: string;
|
|
130
|
+
setup: (build: PluginBuild) => void;
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
/** Wrap the hooks as the one-plugin array both backends take. */
|
|
123
|
-
function hookPlugins(opts: BundleOptions):
|
|
134
|
+
function hookPlugins(opts: BundleOptions): BundlePlugin[] {
|
|
124
135
|
const { onFileLoaded, resolver } = opts;
|
|
125
136
|
if (resolver === undefined && onFileLoaded === undefined) {
|
|
126
137
|
return [];
|
|
@@ -136,7 +147,7 @@ function hookPlugins(opts: BundleOptions): NonNullable<Parameters<typeof Bun.bui
|
|
|
136
147
|
});
|
|
137
148
|
}
|
|
138
149
|
};
|
|
139
|
-
return [{ name: "jx-bundle-hooks", setup }
|
|
150
|
+
return [{ name: "jx-bundle-hooks", setup }];
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
/**
|
|
@@ -184,6 +195,24 @@ function shouldMinify(opts: BundleOptions): boolean {
|
|
|
184
195
|
return opts.minify ?? opts.target === "browser";
|
|
185
196
|
}
|
|
186
197
|
|
|
198
|
+
/** The resolution options both backends spell the same way, present only when they apply. */
|
|
199
|
+
interface SharedResolution {
|
|
200
|
+
conditions?: string[];
|
|
201
|
+
define?: Record<string, string>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Export conditions when the caller gave some, and {@link BROWSER_DEFINE} for browser bundles. */
|
|
205
|
+
function sharedResolution(opts: BundleOptions): SharedResolution {
|
|
206
|
+
const shared: SharedResolution = {};
|
|
207
|
+
if (opts.conditions) {
|
|
208
|
+
shared.conditions = opts.conditions;
|
|
209
|
+
}
|
|
210
|
+
if (opts.target === "browser") {
|
|
211
|
+
shared.define = { ...BROWSER_DEFINE };
|
|
212
|
+
}
|
|
213
|
+
return shared;
|
|
214
|
+
}
|
|
215
|
+
|
|
187
216
|
/** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
|
|
188
217
|
function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
|
|
189
218
|
return target === "node" || target === "bun" ? "node" : "browser";
|
|
@@ -210,8 +239,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
|
|
|
210
239
|
const esbuild = await import("esbuild");
|
|
211
240
|
await esbuild.build({
|
|
212
241
|
bundle: true,
|
|
213
|
-
...(opts
|
|
214
|
-
...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
|
|
242
|
+
...sharedResolution(opts),
|
|
215
243
|
entryPoints: [request.entryPath],
|
|
216
244
|
external,
|
|
217
245
|
format: "esm",
|
|
@@ -219,14 +247,13 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
|
|
|
219
247
|
minify: shouldMinify(opts),
|
|
220
248
|
outfile: request.outfile,
|
|
221
249
|
platform: esbuildPlatform(opts.target),
|
|
222
|
-
plugins
|
|
250
|
+
plugins,
|
|
223
251
|
});
|
|
224
252
|
return;
|
|
225
253
|
}
|
|
226
254
|
|
|
227
255
|
const result = await Bun.build({
|
|
228
|
-
...(opts
|
|
229
|
-
...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
|
|
256
|
+
...sharedResolution(opts),
|
|
230
257
|
entrypoints: [request.entryPath],
|
|
231
258
|
external,
|
|
232
259
|
format: "esm",
|
|
@@ -305,8 +332,7 @@ export async function bundleSource(
|
|
|
305
332
|
const esbuild = await import("esbuild");
|
|
306
333
|
await esbuild.build({
|
|
307
334
|
bundle: true,
|
|
308
|
-
...(opts
|
|
309
|
-
...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
|
|
335
|
+
...sharedResolution(opts),
|
|
310
336
|
external: opts.external ?? [],
|
|
311
337
|
format: "esm",
|
|
312
338
|
logLevel: "silent",
|
|
@@ -26,6 +26,9 @@ import {
|
|
|
26
26
|
useNodeFallback,
|
|
27
27
|
} from "./bundler.ts";
|
|
28
28
|
import { DEFAULT_LIT_HTML_SRC, DEFAULT_REACTIVITY_SRC } from "../shared.ts";
|
|
29
|
+
import { isJsonObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
|
|
30
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
31
|
+
import type { JsonValue } from "@jxsuite/schema/types";
|
|
29
32
|
|
|
30
33
|
/** One import-map entry the build can satisfy itself. */
|
|
31
34
|
interface RuntimeModule {
|
|
@@ -65,6 +68,12 @@ export function isPrefixSpecifier(specifier: string): boolean {
|
|
|
65
68
|
return specifier.endsWith("/");
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/** What a runtime write reports: files written, and the failures it carried on past. */
|
|
72
|
+
export interface RuntimeWriteReport {
|
|
73
|
+
written: number;
|
|
74
|
+
errors: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
export interface ClientRuntime {
|
|
69
78
|
/** The import map's `imports` object. */
|
|
70
79
|
imports: Record<string, string>;
|
|
@@ -125,14 +134,14 @@ export function resolveClientRuntime(resolveDir: string = PACKAGE_DIR): ClientRu
|
|
|
125
134
|
* @param {ClientRuntime} runtime
|
|
126
135
|
* @param {string} outDir
|
|
127
136
|
* @param {string} [resolveDir]
|
|
128
|
-
* @returns {Promise<
|
|
137
|
+
* @returns {Promise<RuntimeWriteReport>}
|
|
129
138
|
*/
|
|
130
139
|
export async function writeClientRuntime(
|
|
131
140
|
runtime: ClientRuntime,
|
|
132
141
|
outDir: string,
|
|
133
142
|
resolveDir: string = PACKAGE_DIR,
|
|
134
143
|
minify = true,
|
|
135
|
-
): Promise<
|
|
144
|
+
): Promise<RuntimeWriteReport> {
|
|
136
145
|
const errors: string[] = [];
|
|
137
146
|
let written = 0;
|
|
138
147
|
|
|
@@ -155,7 +164,7 @@ export async function writeClientRuntime(
|
|
|
155
164
|
);
|
|
156
165
|
written += 1;
|
|
157
166
|
} catch (error) {
|
|
158
|
-
errors.push(`Bundling client runtime "${mod.specifier}": ${(error
|
|
167
|
+
errors.push(`Bundling client runtime "${mod.specifier}": ${errorMessage(error)}`);
|
|
159
168
|
}
|
|
160
169
|
}
|
|
161
170
|
return { errors, written };
|
|
@@ -189,18 +198,21 @@ export function importMapAssets(imports: Record<string, string>): string[] {
|
|
|
189
198
|
export function importMapAssetsInHtml(html: string): string[] {
|
|
190
199
|
const found: string[] = [];
|
|
191
200
|
for (const match of html.matchAll(/<script type="importmap">([\s\S]*?)<\/script>/g)) {
|
|
192
|
-
let
|
|
201
|
+
let map: JsonValue;
|
|
193
202
|
try {
|
|
194
|
-
|
|
203
|
+
/* SAFETY: capture group 1 is not optional in the pattern, so every match carries it, and
|
|
204
|
+
JSON.parse only ever yields JSON values; its `any` return type says nothing more. */
|
|
205
|
+
map = JSON.parse(match[1] as string) as JsonValue;
|
|
195
206
|
} catch {
|
|
196
207
|
// A map the build did not write and cannot parse names nothing this build must produce.
|
|
197
208
|
continue;
|
|
198
209
|
}
|
|
199
|
-
|
|
210
|
+
const imports = isJsonObject(map) ? map.imports : undefined;
|
|
211
|
+
if (!isNonNullObject(imports)) {
|
|
200
212
|
continue;
|
|
201
213
|
}
|
|
202
|
-
const entries = Object.entries(imports
|
|
203
|
-
(
|
|
214
|
+
const entries = Object.entries(imports).flatMap(([specifier, url]): [string, string][] =>
|
|
215
|
+
isString(url) ? [[specifier, url]] : [],
|
|
204
216
|
);
|
|
205
217
|
found.push(...importMapAssets(Object.fromEntries(entries)));
|
|
206
218
|
}
|
|
@@ -224,10 +236,13 @@ export function renderImportMap(imports: Record<string, string>): string {
|
|
|
224
236
|
*/
|
|
225
237
|
function canResolve(specifier: string, resolveDir: string): boolean {
|
|
226
238
|
try {
|
|
239
|
+
// Both resolvers throw when the specifier does not resolve, and return its path when it does.
|
|
227
240
|
if (useNodeFallback()) {
|
|
228
|
-
|
|
241
|
+
createRequire(join(resolveDir, "x.js")).resolve(specifier);
|
|
242
|
+
} else {
|
|
243
|
+
Bun.resolveSync(specifier, resolveDir);
|
|
229
244
|
}
|
|
230
|
-
return
|
|
245
|
+
return true;
|
|
231
246
|
} catch {
|
|
232
247
|
return false;
|
|
233
248
|
}
|
|
@@ -264,13 +279,13 @@ const MAX_SUBPATH_PASSES = 10;
|
|
|
264
279
|
* @param {string} outDir - Build output root
|
|
265
280
|
* @param {string} [resolveDir] - Directory bare specifiers resolve from
|
|
266
281
|
* @param {boolean} [minify] - Minify the emitted bundles (defaults to the browser-target default)
|
|
267
|
-
* @returns {Promise<
|
|
282
|
+
* @returns {Promise<RuntimeWriteReport>}
|
|
268
283
|
*/
|
|
269
284
|
export async function writeRuntimeSubpaths(
|
|
270
285
|
outDir: string,
|
|
271
286
|
resolveDir: string = PACKAGE_DIR,
|
|
272
287
|
minify = true,
|
|
273
|
-
): Promise<
|
|
288
|
+
): Promise<RuntimeWriteReport> {
|
|
274
289
|
const errors: string[] = [];
|
|
275
290
|
const done = new Set<string>();
|
|
276
291
|
/** Output path of a core re-export stub → the bare specifier it must re-export. */
|
|
@@ -293,6 +308,7 @@ export async function writeRuntimeSubpaths(
|
|
|
293
308
|
/** `from "lit-html/directives/class-map.js"` — the import forms a bundler emits. */
|
|
294
309
|
const importedSpecifiers = async (file: string): Promise<string[]> => {
|
|
295
310
|
const text = readFileSync(file, "utf8");
|
|
311
|
+
// SAFETY: capture group 1 is not optional in the pattern, so every match carries it.
|
|
296
312
|
return [...text.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map((m) => m[1] as string);
|
|
297
313
|
};
|
|
298
314
|
|
|
@@ -337,7 +353,7 @@ export async function writeRuntimeSubpaths(
|
|
|
337
353
|
written += 1;
|
|
338
354
|
discovered += 1;
|
|
339
355
|
} catch (error) {
|
|
340
|
-
errors.push(`Bundling runtime subpath "${specifier}": ${(error
|
|
356
|
+
errors.push(`Bundling runtime subpath "${specifier}": ${errorMessage(error)}`);
|
|
341
357
|
}
|
|
342
358
|
}
|
|
343
359
|
}
|
|
@@ -413,7 +429,7 @@ function finish(
|
|
|
413
429
|
mirrors: Map<string, string>,
|
|
414
430
|
errors: string[],
|
|
415
431
|
written: number,
|
|
416
|
-
):
|
|
432
|
+
): RuntimeWriteReport {
|
|
417
433
|
let total = written;
|
|
418
434
|
for (const [path, specifier] of mirrors) {
|
|
419
435
|
if (existsSync(path)) {
|
|
@@ -424,9 +440,7 @@ function finish(
|
|
|
424
440
|
writeFileSync(path, `export * from ${JSON.stringify(specifier)};\n`, "utf8");
|
|
425
441
|
total += 1;
|
|
426
442
|
} catch (error) {
|
|
427
|
-
errors.push(
|
|
428
|
-
`Writing shared core stub ${relative(outDir, path)}: ${(error as Error).message}`,
|
|
429
|
-
);
|
|
443
|
+
errors.push(`Writing shared core stub ${relative(outDir, path)}: ${errorMessage(error)}`);
|
|
430
444
|
}
|
|
431
445
|
}
|
|
432
446
|
return { errors, written: total };
|
package/src/site/csp.ts
CHANGED
|
@@ -19,6 +19,16 @@
|
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
20
20
|
import type { CspConfig, HeadersConfig } from "@jxsuite/schema/types";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The headers a policy adds: the policy itself under its enforcing or report-only name, and the
|
|
24
|
+
* endpoint group its `report-to` names when reporting is on.
|
|
25
|
+
*/
|
|
26
|
+
export interface CspHeaders {
|
|
27
|
+
"Content-Security-Policy"?: string;
|
|
28
|
+
"Content-Security-Policy-Report-Only"?: string;
|
|
29
|
+
"Reporting-Endpoints"?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/** Everything a page contributed to the policy. Accumulated across the whole build. */
|
|
23
33
|
export interface CspSources {
|
|
24
34
|
/** `'sha256-…'` for each distinct inline script. */
|
|
@@ -59,10 +69,9 @@ const FONT_EXTENSION = /\.(woff2?|ttf|otf|eot)(\?|#|$)/i;
|
|
|
59
69
|
|
|
60
70
|
/** Read one double- or single-quoted attribute out of a raw tag attribute string. */
|
|
61
71
|
function attr(attrs: string, name: string): string | undefined {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
.find((v) => v !== undefined);
|
|
72
|
+
// Group 2 is the double-quoted value and group 3 the single-quoted one; only one participates.
|
|
73
|
+
const match = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i").exec(attrs);
|
|
74
|
+
return match?.[2] ?? match?.[3];
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
/** The origin of an absolute http(s) URL, or null for anything relative, `data:` or malformed. */
|
|
@@ -176,12 +185,13 @@ export function normalizeCspConfig(security: HeadersConfig["security"]): CspConf
|
|
|
176
185
|
*
|
|
177
186
|
* @param {CspSources} sources
|
|
178
187
|
* @param {CspConfig} config
|
|
179
|
-
* @returns {
|
|
188
|
+
* @returns {CspHeaders}
|
|
180
189
|
*/
|
|
181
|
-
export function buildCspHeaders(sources: CspSources, config: CspConfig):
|
|
190
|
+
export function buildCspHeaders(sources: CspSources, config: CspConfig): CspHeaders {
|
|
182
191
|
const list = (fixed: string[], origins: Set<string>) => [...fixed, ...origins].join(" ");
|
|
183
192
|
|
|
184
|
-
|
|
193
|
+
// The computed directives, in the same shape as the overrides `config.directives` merges in.
|
|
194
|
+
const directives: NonNullable<CspConfig["directives"]> = {
|
|
185
195
|
"base-uri": "'self'",
|
|
186
196
|
"default-src": "'self'",
|
|
187
197
|
"font-src": list(["'self'"], sources.fontOrigins),
|
|
@@ -212,11 +222,10 @@ export function buildCspHeaders(sources: CspSources, config: CspConfig): Record<
|
|
|
212
222
|
.map(([name, value]) => `${name} ${String(value)}`)
|
|
213
223
|
.join("; ");
|
|
214
224
|
|
|
215
|
-
const
|
|
225
|
+
const headers: CspHeaders =
|
|
216
226
|
config.mode === "report-only"
|
|
217
|
-
? "Content-Security-Policy-Report-Only"
|
|
218
|
-
: "Content-Security-Policy";
|
|
219
|
-
const headers: Record<string, string> = { [headerName]: policy };
|
|
227
|
+
? { "Content-Security-Policy-Report-Only": policy }
|
|
228
|
+
: { "Content-Security-Policy": policy };
|
|
220
229
|
if (config.reportUri !== undefined && config.reportUri !== "") {
|
|
221
230
|
headers["Reporting-Endpoints"] = `csp-endpoint="${config.reportUri}"`;
|
|
222
231
|
}
|
package/src/site/db-push.ts
CHANGED
|
@@ -14,13 +14,13 @@ import { resolve } from "node:path";
|
|
|
14
14
|
import { applyBindingFragments } from "@jxsuite/create/scaffold";
|
|
15
15
|
import { buildProjectExtensionRegistry } from "./format-host.ts";
|
|
16
16
|
import { loadProjectConfig } from "./site-loader.ts";
|
|
17
|
-
import type { FormatEntry } from "@jxsuite/schema/format-registry";
|
|
17
|
+
import type { DataPushRequest, FormatEntry } from "@jxsuite/schema/format-registry";
|
|
18
|
+
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
19
|
+
import type { JsonObject } from "@jxsuite/schema/types";
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
connection?: string;
|
|
23
|
-
}
|
|
21
|
+
/* `jx db push` takes the same request as the Studio data-push route, so the shape is declared once
|
|
22
|
+
in @jxsuite/schema; `DbPushOptions` stays this module's public name for it. */
|
|
23
|
+
export type { DataPushRequest as DbPushOptions } from "@jxsuite/schema/format-registry";
|
|
24
24
|
|
|
25
25
|
export interface ConnectionPushResult {
|
|
26
26
|
connection: string;
|
|
@@ -38,6 +38,10 @@ export interface DbPushResult {
|
|
|
38
38
|
wranglerPath: string | null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* How a push reads a connector's `deploySchema` answer (`DeploySchemaResult`): every member
|
|
43
|
+
* optional, and defaulted where it is read.
|
|
44
|
+
*/
|
|
41
45
|
interface DeployResultLike {
|
|
42
46
|
statements?: string[];
|
|
43
47
|
warnings?: string[];
|
|
@@ -48,19 +52,19 @@ interface DeployResultLike {
|
|
|
48
52
|
* Push the project's table schemas to their connections' databases.
|
|
49
53
|
*
|
|
50
54
|
* @param {string} projectRoot - Absolute path to the project directory
|
|
51
|
-
* @param {
|
|
55
|
+
* @param {DataPushRequest} [options]
|
|
52
56
|
* @returns {Promise<DbPushResult>}
|
|
53
57
|
*/
|
|
54
58
|
export async function dbPush(
|
|
55
59
|
projectRoot: string,
|
|
56
|
-
options:
|
|
60
|
+
options: DataPushRequest = {},
|
|
57
61
|
): Promise<DbPushResult> {
|
|
58
62
|
const { dryRun = false, connection: only } = options;
|
|
59
63
|
const { config } = loadProjectConfig(projectRoot);
|
|
60
64
|
const registry = await buildProjectExtensionRegistry(projectRoot, config);
|
|
61
65
|
|
|
62
|
-
const connections = (config.connections
|
|
63
|
-
const tables = (config.data
|
|
66
|
+
const connections = isJsonObject(config.connections) ? config.connections : {};
|
|
67
|
+
const tables = isJsonObject(config.data) ? config.data : {};
|
|
64
68
|
|
|
65
69
|
const names = Object.keys(connections).filter((name) => !only || name === only);
|
|
66
70
|
if (only && names.length === 0) {
|
|
@@ -73,16 +77,20 @@ export async function dbPush(
|
|
|
73
77
|
);
|
|
74
78
|
}
|
|
75
79
|
|
|
76
|
-
const env:
|
|
80
|
+
const env: NodeJS.ProcessEnv = {
|
|
77
81
|
...process.env,
|
|
78
82
|
...readDevVars(projectRoot),
|
|
79
83
|
JX_PROJECT_ROOT: projectRoot,
|
|
80
84
|
};
|
|
81
85
|
|
|
82
86
|
const results: ConnectionPushResult[] = [];
|
|
83
|
-
const fragments:
|
|
87
|
+
const fragments: JsonObject[] = [];
|
|
84
88
|
for (const name of names) {
|
|
85
|
-
const
|
|
89
|
+
const declared = connections[name];
|
|
90
|
+
// An entry that is not an object declares no provider, so it fails the lookup just below.
|
|
91
|
+
const connection: JsonObject = isJsonObject(declared)
|
|
92
|
+
? { ...declared, $name: name }
|
|
93
|
+
: { $name: name };
|
|
86
94
|
const provider = String(connection.provider ?? "");
|
|
87
95
|
const entry = connectorByProvider(registry.connectors(), provider);
|
|
88
96
|
if (!entry) {
|
|
@@ -91,17 +99,21 @@ export async function dbPush(
|
|
|
91
99
|
`it (enable the extension in project.json "extensions")`,
|
|
92
100
|
);
|
|
93
101
|
}
|
|
94
|
-
const connectionTables: Record<string,
|
|
102
|
+
const connectionTables: Record<string, JsonObject> = {};
|
|
95
103
|
for (const [tableName, def] of Object.entries(tables)) {
|
|
96
|
-
if (def.connection === name) {
|
|
104
|
+
if (isJsonObject(def) && def.connection === name) {
|
|
97
105
|
connectionTables[tableName] = def;
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
/* A connector's `deploySchema` answers `{ statements, applied, warnings }` (extensions.md §8);
|
|
110
|
+
this push reads each member as optional and defaults it where it is read. */
|
|
111
|
+
const deployed: DeployResultLike = await entry.call(
|
|
112
|
+
"deploySchema",
|
|
113
|
+
connectionTables,
|
|
114
|
+
connection,
|
|
115
|
+
{ dryRun, env },
|
|
116
|
+
);
|
|
105
117
|
results.push({
|
|
106
118
|
applied: deployed.applied ?? !dryRun,
|
|
107
119
|
connection: name,
|
|
@@ -112,8 +124,9 @@ export async function dbPush(
|
|
|
112
124
|
});
|
|
113
125
|
|
|
114
126
|
if (entry.capabilities.bindings) {
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
// A connector's `bindings` answers with a wrangler config fragment (extensions.md §8).
|
|
128
|
+
const fragment = await entry.call("bindings", connection);
|
|
129
|
+
if (isJsonObject(fragment) && Object.keys(fragment).length > 0) {
|
|
117
130
|
fragments.push(fragment);
|
|
118
131
|
}
|
|
119
132
|
}
|
|
@@ -152,7 +165,8 @@ export function readDevVars(projectRoot: string): Record<string, string> {
|
|
|
152
165
|
if (!existsSync(path)) {
|
|
153
166
|
return {};
|
|
154
167
|
}
|
|
155
|
-
|
|
168
|
+
// A later line for the same key replaces an earlier one.
|
|
169
|
+
const vars = new Map<string, string>();
|
|
156
170
|
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
|
|
157
171
|
const line = rawLine.trim();
|
|
158
172
|
if (line === "" || line.startsWith("#")) {
|
|
@@ -171,8 +185,8 @@ export function readDevVars(projectRoot: string): Record<string, string> {
|
|
|
171
185
|
value = value.slice(1, -1);
|
|
172
186
|
}
|
|
173
187
|
if (key !== "") {
|
|
174
|
-
|
|
188
|
+
vars.set(key, value);
|
|
175
189
|
}
|
|
176
190
|
}
|
|
177
|
-
return
|
|
191
|
+
return Object.fromEntries(vars);
|
|
178
192
|
}
|
package/src/site/dev-command.ts
CHANGED
|
@@ -36,8 +36,8 @@ export function resolveDevEntry(projectRoot: string): string {
|
|
|
36
36
|
|
|
37
37
|
/** The slice of `process` runDev touches — injectable for tests. */
|
|
38
38
|
export interface ProcessLike {
|
|
39
|
-
on: (event: string, handler: () => void) =>
|
|
40
|
-
exitCode?: number | string | undefined;
|
|
39
|
+
on: (event: string, handler: () => void) => void;
|
|
40
|
+
exitCode?: number | string | null | undefined;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -53,7 +53,7 @@ export function runDev(
|
|
|
53
53
|
projectRoot: string,
|
|
54
54
|
port: number | undefined,
|
|
55
55
|
spawnImpl: typeof spawn = spawn,
|
|
56
|
-
proc: ProcessLike = process
|
|
56
|
+
proc: ProcessLike = process,
|
|
57
57
|
): ChildProcess {
|
|
58
58
|
const entry = resolveDevEntry(projectRoot);
|
|
59
59
|
const args = [entry, "--root", projectRoot];
|