@johnhenry/sitefile 0.0.0 → 0.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +58 -20
  3. package/lib/src/checklinks.js +2 -0
  4. package/lib/src/cli/build-image.js +2 -2
  5. package/lib/src/cli/cache.js +15 -5
  6. package/lib/src/cli/images.js +3 -3
  7. package/lib/src/cli/index.d.ts +1 -1
  8. package/lib/src/cli/index.js +1 -1
  9. package/lib/src/cli/network.d.ts +34 -0
  10. package/lib/src/cli/network.js +56 -0
  11. package/lib/src/cli/outdated.js +5 -0
  12. package/lib/src/cli/registry.js +6 -1
  13. package/lib/src/cli/servers.js +23 -7
  14. package/lib/src/cli/vendor.js +5 -0
  15. package/lib/src/cli.js +44 -8
  16. package/lib/src/errors.d.ts +25 -3
  17. package/lib/src/errors.js +9 -0
  18. package/lib/src/executor.d.ts +7 -0
  19. package/lib/src/executor.js +18 -10
  20. package/lib/src/export-formats.d.ts +10 -3
  21. package/lib/src/export-formats.js +17 -6
  22. package/lib/src/index.d.ts +2 -1
  23. package/lib/src/index.js +2 -1
  24. package/lib/src/language/ast.d.ts +9 -2
  25. package/lib/src/outdated.js +1 -1
  26. package/lib/src/planner.d.ts +1 -1
  27. package/lib/src/planner.js +2 -2
  28. package/lib/src/plugins/cache.d.ts +29 -3
  29. package/lib/src/plugins/cache.js +104 -5
  30. package/lib/src/registry.d.ts +6 -0
  31. package/lib/src/registry.js +38 -19
  32. package/lib/src/resources/https.d.ts +35 -1
  33. package/lib/src/resources/https.js +175 -18
  34. package/lib/src/resources/network.d.ts +70 -0
  35. package/lib/src/resources/network.js +304 -0
  36. package/lib/src/resources/types.d.ts +6 -0
  37. package/lib/src/secrets.js +1 -1
  38. package/lib/src/serve-rules.js +2 -1
  39. package/lib/src/servers.d.ts +14 -0
  40. package/lib/src/servers.js +89 -3
  41. package/lib/src/vendor.js +1 -1
  42. package/lib/src/wbn.d.ts +21 -0
  43. package/lib/src/wbn.js +111 -17
  44. package/package.json +2 -1
@@ -1,3 +1,4 @@
1
+ import { NETWORK_OPTIONS, networkHelp, networkOption } from './network.js';
1
2
  import path from 'node:path';
2
3
  import { insecureRegistriesFromEnv } from '../base-image.js';
3
4
  import { collectBuildArgs } from '../build-args.js';
@@ -31,11 +32,13 @@ const vendorCommand = {
31
32
  '--allow-private-network permit loopback/private network addresses',
32
33
  `--max-download-bytes <n> per-resource size limit (default ${DEFAULT_FETCH_POLICY.maxBytes})`,
33
34
  `--timeout <ms> per-resource fetch timeout (default ${DEFAULT_FETCH_POLICY.timeoutMs})`,
35
+ ...networkHelp(30),
34
36
  '--base <image> vendor this BASE image (overrides EXPORT ... AS oci BASE)',
35
37
  '--platform <os/arch> BASE platform to vendor (default linux/amd64)',
36
38
  '--insecure-registry <h> allow plain http to registry host[:port] (repeatable)',
37
39
  ],
38
40
  options: {
41
+ ...NETWORK_OPTIONS,
39
42
  file: { type: 'string', short: 'f' },
40
43
  'vendor-dir': { type: 'string' },
41
44
  'build-arg': { type: 'string', multiple: true },
@@ -58,6 +61,7 @@ const vendorCommand = {
58
61
  const context = pos[0] ?? '.';
59
62
  const maxBytes = positive(str(v['max-download-bytes']), '--max-download-bytes');
60
63
  const timeoutMs = positive(str(v.timeout), '--timeout');
64
+ const network = networkOption(v);
61
65
  const file = str(v.file);
62
66
  const vendorDir = str(v['vendor-dir']);
63
67
  const base = str(v.base);
@@ -72,6 +76,7 @@ const vendorCommand = {
72
76
  allowPrivateNetwork: !!v['allow-private-network'],
73
77
  ...(maxBytes ? { maxBytes } : {}),
74
78
  ...(timeoutMs ? { timeoutMs } : {}),
79
+ ...(network ? { network } : {}),
75
80
  },
76
81
  ...(vendorDir !== undefined ? { vendorDir } : {}),
77
82
  ...(base !== undefined ? { baseImage: base } : {}),
package/lib/src/cli.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { parseArgs } from 'node:util';
3
3
  import path from 'node:path';
4
4
  import { SitefileError, isSitefileError } from './errors.js';
5
- import { build, buildToDirectory, checkPlanPins, lintBuild, planBuild } from './executor.js';
5
+ import { build, buildToDirectory, checkPlanPins, lintBuild, planBuild, sitefileCompression } from './executor.js';
6
6
  import { LINT_RULES } from './lint.js';
7
- import { defaultOutName, formatFromExtension } from './export-formats.js';
7
+ import { compressionFromExtension, defaultOutName, formatFromExtension } from './export-formats.js';
8
8
  import { collectBuildArgs } from './build-args.js';
9
9
  import { insecureRegistriesFromEnv, makeClient } from './base-image.js';
10
10
  import { cacheLimitFromEnv } from './cache-limit.js';
@@ -24,6 +24,7 @@ import { promises as fs } from 'node:fs';
24
24
  import { graphDot, graphText, planGraph, why, whyText } from './explain.js';
25
25
  import { isGitContext, materializeGitContext } from './cli/git-context.js';
26
26
  import { loadSecrets } from './secrets.js';
27
+ import { NETWORK_OPTIONS, networkHelp, networkOption } from './cli/network.js';
27
28
  const VERSION = SITEFILE_VERSION;
28
29
  const HELP = `sitefile ${VERSION} — Docker-inspired static artifact builder
29
30
 
@@ -43,16 +44,19 @@ Options:
43
44
  --target <stage> stop after the named stage and export its whole tree instead of
44
45
  the EXPORT path (EXPORT's format still applies; no _redirects/_headers)
45
46
  -o, --out <path> export destination (build only): a directory for 'directory',
46
- a file for zip, tar, tgz, wbn
47
+ a file for zip, tar, tgz, wbn (*.wbn.gz: a gzipped bundle)
47
48
  -t, --tag <name[:tag]> also store the build as a local image (build only, repeatable);
48
49
  with -t and no -o nothing is written to the context
49
50
  --format <fmt> directory | zip | tar | tgz (alias tar.gz) | wbn | oci (build only).
50
51
  oci writes an OCI archive for -o *.tar (e.g. site.oci.tar), else an
51
52
  OCI image layout directory.
52
- Default: the --out extension (.zip .tar .tar.gz .tgz .wbn .oci.tar),
53
+ Default: the --out extension (.zip .tar .tar.gz .tgz .wbn .wbn.gz .oci.tar),
53
54
  else the Sitefile's EXPORT ... AS <fmt>
54
55
  --oci-root <path> directory inside an oci image layer (default /srv/www; overrides EXPORT ... AT)
55
- --oci-compression <c> oci site layer: gzip (default) or none (plain tar; overrides EXPORT ... COMPRESSION)
56
+ --compression <c> gzip or none (build only; overrides EXPORT ... COMPRESSION): the oci site layer
57
+ (default gzip; none: plain tar), or the whole wbn (default none; gzip:
58
+ gzip(application/webbundle), also chosen by -o *.wbn.gz)
59
+ --oci-compression <c> the same as --compression, for oci only
56
60
  --label <key=value> add an OCI image config label (repeatable)
57
61
  --git-labels add org.opencontainers.image.source/revision from the git checkout
58
62
  (off by default: they change the image ID on every commit)
@@ -98,6 +102,7 @@ Options:
98
102
  --allow-hardlinks permit hard-linked files in the context (you assert they are safe)
99
103
  --max-download-bytes <n> per-resource size limit (default ${DEFAULT_FETCH_POLICY.maxBytes})
100
104
  --timeout <ms> per-resource fetch timeout (default ${DEFAULT_FETCH_POLICY.timeoutMs})
105
+ ${networkHelp(25).map((l) => ` ${l}`).join('\n')}
101
106
  --json machine-readable output (validate, inspect, graph, why)
102
107
  --dot Graphviz DOT output (graph only)
103
108
  --watch build, then rebuild when the context, the Sitefile or a
@@ -121,6 +126,7 @@ export async function main(argv, run = {}) {
121
126
  args: [...argv],
122
127
  allowPositionals: true,
123
128
  options: {
129
+ ...NETWORK_OPTIONS,
124
130
  file: { type: 'string', short: 'f' },
125
131
  target: { type: 'string' },
126
132
  out: { type: 'string', short: 'o' },
@@ -129,6 +135,7 @@ export async function main(argv, run = {}) {
129
135
  'base-url': { type: 'string' },
130
136
  'oci-root': { type: 'string' },
131
137
  'oci-compression': { type: 'string' },
138
+ compression: { type: 'string' },
132
139
  label: { type: 'string', multiple: true },
133
140
  'git-labels': { type: 'boolean' },
134
141
  provenance: { type: 'boolean' },
@@ -255,11 +262,13 @@ export async function main(argv, run = {}) {
255
262
  let failed = false;
256
263
  let fetched;
257
264
  try {
265
+ const network = networkOption(o);
258
266
  const fetchPolicy = {
259
267
  allowHttp: !!o['allow-http'],
260
268
  allowPrivateNetwork: !!o['allow-private-network'],
261
269
  ...(o['max-download-bytes'] ? { maxBytes: positiveInt(o['max-download-bytes'], '--max-download-bytes') } : {}),
262
270
  ...(o.timeout ? { timeoutMs: positiveInt(o.timeout, '--timeout') } : {}),
271
+ ...(network ? { network } : {}),
263
272
  };
264
273
  if (gitContext) {
265
274
  // A Git URL context: fetched into a temporary directory, built from there, then removed.
@@ -272,7 +281,7 @@ export async function main(argv, run = {}) {
272
281
  ? new RegistryCache({
273
282
  from: cacheFrom.map((r) => parseCacheRef('--cache-from', r)),
274
283
  to: cacheTo[0] !== undefined ? parseCacheRef('--cache-to', cacheTo[0]) : undefined,
275
- client: (registry) => makeClient(registry, { insecureRegistries: insecureRegistriesFromEnv(o['insecure-registry'] ?? []), allowPrivateNetwork: !!o['allow-private-network'] }),
284
+ client: (registry) => makeClient(registry, { insecureRegistries: insecureRegistriesFromEnv(o['insecure-registry'] ?? []), allowPrivateNetwork: !!o['allow-private-network'], network }),
276
285
  limit: cacheLimitFromEnv(),
277
286
  warn: (m) => process.stderr.write(`warning: ${m}\n`),
278
287
  notice: (m) => process.stderr.write(`${m}\n`),
@@ -293,14 +302,26 @@ export async function main(argv, run = {}) {
293
302
  if (!ociCompression)
294
303
  return usageError(`--oci-compression expects gzip or none, got '${o['oci-compression']}'`);
295
304
  }
305
+ let compression;
306
+ if (o.compression !== undefined) {
307
+ compression = parseOciCompression(o.compression);
308
+ if (!compression)
309
+ return usageError(`--compression expects gzip or none, got '${o.compression}'`);
310
+ if (ociCompression !== undefined)
311
+ return usageError('--compression and --oci-compression set the same thing; give one');
312
+ }
296
313
  // No --format: an archive extension on --out picks the format, so `-o site.zip`
297
314
  // writes a zip instead of a directory named site.zip.
298
315
  if (!formatOverride && o.out)
299
316
  formatOverride = formatFromExtension(o.out);
300
317
  // The default destination depends on the format, which the Sitefile may set.
301
- const sitefileFormat = command === 'build' && !o.out && !formatOverride ? (await planBuild({ contextDir: context, ...(file ? { sitefile: file } : {}), buildArgs })).exportFormat : undefined;
318
+ // (A wbn's default name also depends on its COMPRESSION: dist.wbn or dist.wbn.gz.)
319
+ const sitefilePlan = command === 'build' && !o.out && (!formatOverride || formatOverride === 'wbn') ? await planBuild({ contextDir: context, ...(file ? { sitefile: file } : {}), buildArgs }) : undefined;
320
+ const sitefileFormat = formatOverride ? undefined : sitefilePlan?.exportFormat;
321
+ const defaultFormat = formatOverride ?? sitefileFormat ?? 'directory';
322
+ const defaultCompression = defaultFormat === 'wbn' ? (compression ?? (sitefilePlan ? sitefileCompression(sitefilePlan, 'wbn') : undefined)) : undefined;
302
323
  // A Git context's default destination is in the current directory, not the temporary checkout.
303
- const out = o.out ?? path.join(fetched ? '.' : context, defaultOutName(formatOverride ?? sitefileFormat ?? 'directory'));
324
+ const out = o.out ?? path.join(fetched ? '.' : context, defaultOutName(defaultFormat, defaultCompression));
304
325
  run.wrote?.push(out);
305
326
  const options = {
306
327
  contextDir: context,
@@ -335,6 +356,20 @@ export async function main(argv, run = {}) {
335
356
  ...(profiler ? { profiler, alsoWrites: [o.profile] } : {}),
336
357
  ...(run.memo ? { memo: run.memo } : {}),
337
358
  };
359
+ // --compression belongs to the export's own format, oci or wbn (-t without -o stores an oci image);
360
+ // a *.wbn.gz destination means a gzipped bundle.
361
+ let wbnCompression;
362
+ if (command === 'build' && compression !== undefined) {
363
+ const fmt = (o.tag ?? []).length && !o.out ? 'oci' : (formatOverride ?? sitefileFormat ?? (await planBuild(options)).exportFormat);
364
+ if (fmt === 'oci')
365
+ ociCompression = compression;
366
+ else if (fmt === 'wbn')
367
+ wbnCompression = compression;
368
+ else
369
+ return usageError(`--compression applies only to oci and wbn exports, not ${fmt}`);
370
+ }
371
+ if (formatOverride === 'wbn' && o.out)
372
+ wbnCompression ??= compressionFromExtension(o.out, 'wbn');
338
373
  switch (command) {
339
374
  case 'build': {
340
375
  const tags = o.tag ?? [];
@@ -369,6 +404,7 @@ export async function main(argv, run = {}) {
369
404
  ...(o['base-url'] !== undefined ? { baseUrl: o['base-url'] } : {}),
370
405
  ...(o['oci-root'] !== undefined ? { ociRoot: o['oci-root'] } : {}),
371
406
  ...(ociCompression !== undefined ? { ociCompression } : {}),
407
+ ...(wbnCompression !== undefined ? { wbnCompression } : {}),
372
408
  ...(o.platform !== undefined ? { platform: o.platform } : {}),
373
409
  imageLabels: labels,
374
410
  gitLabels: !!o['git-labels'],
@@ -10,7 +10,7 @@ export interface SourceSpan {
10
10
  */
11
11
  export type ErrorCode = 'E_SYNTAX' | 'E_UNKNOWN_INSTRUCTION' | 'E_UNSUPPORTED' | 'E_VALIDATION' | 'E_UNDEFINED' | 'E_DUPLICATE' | 'E_MISSING_ARG' | 'E_TYPE' | 'E_PATH' | 'E_PATH_ESCAPE' | 'E_NOT_FOUND' | 'E_UNSUPPORTED_FILE' | 'E_HARDLINK'
12
12
  /** Two paths equal under Unicode simple case folding, or two context names equal after NFC. */
13
- | 'E_PATH_COLLISION' | 'E_CONFLICT' | 'E_PROTOCOL' | 'E_SSRF_BLOCKED' | 'E_FETCH' | 'E_FETCH_STATUS' | 'E_FETCH_TIMEOUT' | 'E_TOO_LARGE' | 'E_REDIRECT' | 'E_INTEGRITY' | 'E_LOCK_MISMATCH' | 'E_LOCK_MISSING'
13
+ | 'E_PATH_COLLISION' | 'E_CONFLICT' | 'E_PROTOCOL' | 'E_SSRF_BLOCKED' | 'E_HOST_BLOCKED' | 'E_FETCH' | 'E_FETCH_STATUS' | 'E_FETCH_TIMEOUT' | 'E_TOO_LARGE' | 'E_REDIRECT' | 'E_INTEGRITY' | 'E_LOCK_MISMATCH' | 'E_LOCK_MISSING'
14
14
  /** A remote input (IMPORT URL or BASE image) has no pin, under --strict-pins. */
15
15
  | 'E_UNPINNED'
16
16
  /** A malformed or unsupported Git smart HTTP response or packfile (git+https IMPORT, Git URL contexts). */
@@ -22,13 +22,35 @@ export type ErrorCode = 'E_SYNTAX' | 'E_UNKNOWN_INSTRUCTION' | 'E_UNSUPPORTED' |
22
22
  /** CHECKLINKS found references to files that do not exist (or, with --external, URLs that do not answer). */
23
23
  | 'E_BROKEN_LINK' | 'E_EXPORT' | 'E_EXPORT_UNSAFE' | 'E_STORE' | 'E_SERVER' | 'E_REGISTRY' | 'E_AUTH'
24
24
  /** One or more `sitefile bake` targets failed. */
25
- | 'E_BAKE' | 'E_USAGE';
25
+ | 'E_BAKE' | 'E_USAGE'
26
+ /** The build was stopped by its embedder (the Rust library's `CancelToken`; see docs/RUST.md). */
27
+ | 'E_CANCELLED';
28
+ /**
29
+ * The permission a build needs: carried by the `E_UNSUPPORTED` error of a JavaScript PLUGIN
30
+ * (`--allow-plugin <name>`) or a TOOL (`--allow-exec <name>`) that was not allowed, so callers
31
+ * need not parse the message.
32
+ */
33
+ export interface Needs {
34
+ readonly kind: 'plugin' | 'tool';
35
+ /** The declared PLUGIN or TOOL name. */
36
+ readonly name: string;
37
+ /** The flag that grants it. */
38
+ readonly flag: '--allow-plugin' | '--allow-exec';
39
+ }
40
+ export declare function needsPlugin(name: string): Needs;
41
+ export declare function needsTool(name: string): Needs;
42
+ export interface SitefileErrorOptions extends ErrorOptions {
43
+ /** What must be allowed (permission errors only). */
44
+ readonly needs?: Needs;
45
+ }
26
46
  export declare class SitefileError extends Error {
27
47
  readonly code: ErrorCode;
28
48
  readonly span: SourceSpan | undefined;
29
49
  /** The message without the location prefix. */
30
50
  readonly detail: string;
31
- constructor(code: ErrorCode, detail: string, span?: SourceSpan, options?: ErrorOptions);
51
+ /** For a permission error (`E_UNSUPPORTED` at a PLUGIN or TOOL line): what must be allowed. Not part of the message. */
52
+ readonly needs: Needs | undefined;
53
+ constructor(code: ErrorCode, detail: string, span?: SourceSpan, options?: SitefileErrorOptions);
32
54
  }
33
55
  export declare function formatSpan(span: SourceSpan): string;
34
56
  export declare function isSitefileError(e: unknown, code?: ErrorCode): e is SitefileError;
package/lib/src/errors.js CHANGED
@@ -1,14 +1,23 @@
1
+ export function needsPlugin(name) {
2
+ return { kind: 'plugin', name, flag: '--allow-plugin' };
3
+ }
4
+ export function needsTool(name) {
5
+ return { kind: 'tool', name, flag: '--allow-exec' };
6
+ }
1
7
  export class SitefileError extends Error {
2
8
  code;
3
9
  span;
4
10
  /** The message without the location prefix. */
5
11
  detail;
12
+ /** For a permission error (`E_UNSUPPORTED` at a PLUGIN or TOOL line): what must be allowed. Not part of the message. */
13
+ needs;
6
14
  constructor(code, detail, span, options) {
7
15
  super(span ? `${formatSpan(span)}: ${detail}` : detail, options);
8
16
  this.name = 'SitefileError';
9
17
  this.code = code;
10
18
  this.span = span;
11
19
  this.detail = detail;
20
+ this.needs = options?.needs;
12
21
  }
13
22
  }
14
23
  export function formatSpan(span) {
@@ -198,6 +198,8 @@ export interface ExportTarget {
198
198
  readonly ociRoot?: string;
199
199
  /** oci: site layer encoding (overrides EXPORT ... COMPRESSION; default gzip). */
200
200
  readonly ociCompression?: OciCompression;
201
+ /** wbn: `gzip` wraps the bundle in gzip (overrides EXPORT ... COMPRESSION; default none). */
202
+ readonly wbnCompression?: OciCompression;
201
203
  /** Extra OCI config labels. */
202
204
  readonly imageLabels?: Readonly<Record<string, string>>;
203
205
  /** Add org.opencontainers.image.source/revision from the git checkout (opt-in). */
@@ -231,6 +233,11 @@ export declare function buildToDirectory(options: BuildOptions & ExportTarget &
231
233
  readonly outDir: string;
232
234
  readonly force?: boolean;
233
235
  }): Promise<ExportedBuild>;
236
+ /**
237
+ * The Sitefile's `EXPORT ... COMPRESSION` for an export in `format`: it belongs to the EXPORT's own format,
238
+ * so a `--format` override to another format does not inherit it (undefined: that format's default).
239
+ */
240
+ export declare function sitefileCompression(plan: Pick<Plan, 'exportFormat' | 'exportCompression'>, format: ExportFormat): OciCompression | undefined;
234
241
  /**
235
242
  * The attestations (`--provenance`, `--sbom`) of an image built from `result`: in-toto statements
236
243
  * whose subject is the image manifest. They never change the image itself.
@@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } fro
2
2
  import { statSync } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { SitefileError } from './errors.js';
5
+ import { needsPlugin, needsTool, SitefileError } from './errors.js';
6
6
  import { sha256 } from './digest.js';
7
7
  import { FEED_ITEM } from './language/ast.js';
8
8
  import { fragmentText, parseProgram } from './language/validate.js';
@@ -237,7 +237,7 @@ async function buildWith(options, tempDirs) {
237
237
  vendor,
238
238
  registry: {
239
239
  ...options.registry,
240
- allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork,
240
+ allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork, network: options.registry?.network ?? policy.network,
241
241
  },
242
242
  warn,
243
243
  span: b.span,
@@ -388,7 +388,7 @@ async function buildWith(options, tempDirs) {
388
388
  vendor,
389
389
  registry: {
390
390
  ...options.registry,
391
- allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork,
391
+ allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork, network: options.registry?.network ?? policy.network,
392
392
  },
393
393
  warn,
394
394
  })
@@ -492,7 +492,7 @@ async function exportBuild(result, format, options) {
492
492
  if (format === 'oci') {
493
493
  const img = buildOciImage(result.output, {
494
494
  root: options.ociRoot ?? result.plan.exportRoot,
495
- compression: options.ociCompression ?? result.plan.exportCompression,
495
+ compression: options.ociCompression ?? sitefileCompression(result.plan, 'oci'),
496
496
  platform: options.platform,
497
497
  epoch: sourceDateEpoch(),
498
498
  base: result.base,
@@ -517,10 +517,18 @@ async function exportBuild(result, format, options) {
517
517
  const data = encodeTree(result.output, format, {
518
518
  epoch: sourceDateEpoch(),
519
519
  baseUrl: options.baseUrl ?? result.plan.exportBase,
520
+ ...(format === 'wbn' ? { compression: options.wbnCompression ?? sitefileCompression(result.plan, 'wbn') } : {}),
520
521
  });
521
522
  const written = await exportFile(data, options.outDir, exportOptions);
522
523
  return { ...result, format, outDir: written.outFile, bytes: written.bytes, fileDigest: sha256(data) };
523
524
  }
525
+ /**
526
+ * The Sitefile's `EXPORT ... COMPRESSION` for an export in `format`: it belongs to the EXPORT's own format,
527
+ * so a `--format` override to another format does not inherit it (undefined: that format's default).
528
+ */
529
+ export function sitefileCompression(plan, format) {
530
+ return plan.exportFormat === format ? plan.exportCompression : undefined;
531
+ }
524
532
  /**
525
533
  * The attestations (`--provenance`, `--sbom`) of an image built from `result`: in-toto statements
526
534
  * whose subject is the image manifest. They never change the image itself.
@@ -1377,7 +1385,7 @@ function checkPlugins(p, options, warn) {
1377
1385
  const allowed = (spec) => {
1378
1386
  if (all || names.has(spec.name))
1379
1387
  return;
1380
- throw new SitefileError('E_UNSUPPORTED', `PLUGIN ${spec.name} runs JavaScript, which must be allowed explicitly: add --allow-plugin ${spec.name} (or --allow-plugins to allow every PLUGIN); WebAssembly plugins need no flag`, spec.span);
1388
+ throw new SitefileError('E_UNSUPPORTED', `PLUGIN ${spec.name} runs JavaScript, which must be allowed explicitly: add --allow-plugin ${spec.name} (or --allow-plugins to allow every PLUGIN); WebAssembly plugins need no flag`, spec.span, { needs: needsPlugin(spec.name) });
1381
1389
  };
1382
1390
  for (const spec of p.plugins)
1383
1391
  if (!spec.path.endsWith('.wasm'))
@@ -1401,7 +1409,7 @@ async function resolveTools(p, options, warn) {
1401
1409
  const out = [];
1402
1410
  for (const t of p.tools) {
1403
1411
  if (!allowed.has(t.name)) {
1404
- throw new SitefileError('E_UNSUPPORTED', `TOOL ${t.name} runs an external program, which must be allowed explicitly: add --allow-exec ${t.name} (or --allow-exec ${t.name}=/absolute/path)`, t.span);
1412
+ throw new SitefileError('E_UNSUPPORTED', `TOOL ${t.name} runs an external program, which must be allowed explicitly: add --allow-exec ${t.name} (or --allow-exec ${t.name}=/absolute/path)`, t.span, { needs: needsTool(t.name) });
1405
1413
  }
1406
1414
  const tool = await resolveTool(t.name, allowed.get(t.name), process.env['PATH'], t.span);
1407
1415
  options.onNotice?.(`tool ${t.name} -> ${tool.path}`);
@@ -1547,7 +1555,7 @@ async function produceTransform(op, env, opId) {
1547
1555
  })
1548
1556
  : undefined;
1549
1557
  const call = begin(env.profiler, `TRANSFORM ${op.plugin} ${dest.path}`, 'transform', { plugin: op.plugin, tool: tool !== undefined, dest: dest.path });
1550
- let result = cached ? await env.cache.get(cached) : undefined;
1558
+ let result = cached ? await env.cache.get(cached, op.plugin) : undefined;
1551
1559
  const cacheState = !cached ? 'off' : result ? 'hit' : 'miss';
1552
1560
  if (!result) {
1553
1561
  if (tool) {
@@ -1560,7 +1568,7 @@ async function produceTransform(op, env, opId) {
1560
1568
  result = await env.plugins.call(op.plugin, request, op.span);
1561
1569
  }
1562
1570
  if (cached && (tool || env.plugins.cacheable(op.plugin)))
1563
- await env.cache.put(cached, result);
1571
+ await env.cache.put(cached, result, op.plugin);
1564
1572
  }
1565
1573
  call.end({ cache: cacheState });
1566
1574
  if (result.kind === 'file') {
@@ -1722,7 +1730,7 @@ async function transformFiles(op, env, dir, ctx) {
1722
1730
  cache: cached !== undefined ? 'miss' : 'off',
1723
1731
  };
1724
1732
  if (cached !== undefined) {
1725
- const hit = await env.cache.get(cached);
1733
+ const hit = await env.cache.get(cached, op.plugin);
1726
1734
  if (hit) {
1727
1735
  span.start();
1728
1736
  span.end({ cache: 'hit' });
@@ -1766,7 +1774,7 @@ async function transformFiles(op, env, dir, ctx) {
1766
1774
  throw namingFile(job.error.error, job.label);
1767
1775
  const result = job.result;
1768
1776
  if (job.cache === 'miss' && store())
1769
- await env.cache.put(job.cached, result);
1777
+ await env.cache.put(job.cached, result, op.plugin);
1770
1778
  const files = [];
1771
1779
  if (result.kind === 'file') {
1772
1780
  if (job.dest.trailingSlash) {
@@ -1,16 +1,23 @@
1
1
  import { type WriterOptions } from './archive-writers.js';
2
- import type { ExportFormat } from './language/ast.js';
2
+ import type { ExportFormat, OciCompression } from './language/ast.js';
3
3
  import type { Tree } from './tree.js';
4
4
  export type ArchiveFormat = Exclude<ExportFormat, 'directory' | 'oci'>;
5
5
  /** Single-file formats encoded by {@link encodeTree}. `oci` is handled separately (layout directory or OCI archive). */
6
6
  export declare function isArchiveFormat(f: ExportFormat): f is ArchiveFormat;
7
- /** Default output path relative to the context: `dist`, `dist.zip`, `dist.tar`, `dist.tar.gz`, `dist.wbn`. */
8
- export declare function defaultOutName(format: ExportFormat): string;
7
+ /**
8
+ * Default output path relative to the context: `dist`, `dist.zip`, `dist.tar`, `dist.tar.gz`, `dist.wbn`
9
+ * (`dist.wbn.gz` for a gzipped bundle), `dist.oci.tar`.
10
+ */
11
+ export declare function defaultOutName(format: ExportFormat, compression?: OciCompression): string;
9
12
  export interface EncodeOptions extends WriterOptions {
10
13
  /** Required for `wbn`: absolute https URL ending in `/`. */
11
14
  readonly baseUrl?: string | undefined;
15
+ /** `wbn`: `gzip` wraps the finished bundle in gzip (the shared deterministic encoder); default none. */
16
+ readonly compression?: OciCompression | undefined;
12
17
  }
13
18
  /** Serialize a tree in an archive format. Deterministic: see archive-writers.ts and wbn.ts. */
14
19
  export declare function encodeTree(tree: Tree, format: ArchiveFormat, options?: EncodeOptions): Uint8Array;
15
20
  /** The export format implied by an output file name, if it has an archive extension. */
16
21
  export declare function formatFromExtension(out: string): ExportFormat | undefined;
22
+ /** The compression implied by an output file name for `format`: `gzip` for a wbn written to `*.wbn.gz`. */
23
+ export declare function compressionFromExtension(out: string, format: ExportFormat): OciCompression | undefined;
@@ -1,11 +1,15 @@
1
1
  import { writeTar, writeTgz, writeZip } from './archive-writers.js';
2
+ import { gzip } from './deflate.js';
2
3
  import { writeWebBundle } from './wbn.js';
3
4
  /** Single-file formats encoded by {@link encodeTree}. `oci` is handled separately (layout directory or OCI archive). */
4
5
  export function isArchiveFormat(f) {
5
6
  return f !== 'directory' && f !== 'oci';
6
7
  }
7
- /** Default output path relative to the context: `dist`, `dist.zip`, `dist.tar`, `dist.tar.gz`, `dist.wbn`. */
8
- export function defaultOutName(format) {
8
+ /**
9
+ * Default output path relative to the context: `dist`, `dist.zip`, `dist.tar`, `dist.tar.gz`, `dist.wbn`
10
+ * (`dist.wbn.gz` for a gzipped bundle), `dist.oci.tar`.
11
+ */
12
+ export function defaultOutName(format, compression) {
9
13
  switch (format) {
10
14
  case 'directory':
11
15
  return 'dist';
@@ -16,7 +20,7 @@ export function defaultOutName(format) {
16
20
  case 'tgz':
17
21
  return 'dist.tar.gz';
18
22
  case 'wbn':
19
- return 'dist.wbn';
23
+ return compression === 'gzip' ? 'dist.wbn.gz' : 'dist.wbn';
20
24
  case 'oci':
21
25
  return 'dist.oci.tar';
22
26
  }
@@ -30,8 +34,11 @@ export function encodeTree(tree, format, options = {}) {
30
34
  return writeTar(tree, options);
31
35
  case 'tgz':
32
36
  return writeTgz(tree, options);
33
- case 'wbn':
34
- return writeWebBundle(tree, options.baseUrl);
37
+ case 'wbn': {
38
+ // The bundle is written first, unchanged; gzip only wraps its bytes (header mtime 0, see deflate.ts).
39
+ const bundle = writeWebBundle(tree, options.baseUrl);
40
+ return options.compression === 'gzip' ? gzip(bundle) : bundle;
41
+ }
35
42
  }
36
43
  }
37
44
  /** The export format implied by an output file name, if it has an archive extension. */
@@ -45,8 +52,12 @@ export function formatFromExtension(out) {
45
52
  return 'tar';
46
53
  if (name.endsWith('.zip'))
47
54
  return 'zip';
48
- if (name.endsWith('.wbn'))
55
+ if (name.endsWith('.wbn') || name.endsWith('.wbn.gz'))
49
56
  return 'wbn';
50
57
  return undefined;
51
58
  }
59
+ /** The compression implied by an output file name for `format`: `gzip` for a wbn written to `*.wbn.gz`. */
60
+ export function compressionFromExtension(out, format) {
61
+ return format === 'wbn' && out.toLowerCase().endsWith('.wbn.gz') ? 'gzip' : undefined;
62
+ }
52
63
  //# sourceMappingURL=export-formats.js.map
@@ -1,4 +1,4 @@
1
- export { SitefileError, isSitefileError, formatSpan, type ErrorCode, type SourceSpan } from './errors.js';
1
+ export { SitefileError, isSitefileError, formatSpan, needsPlugin, needsTool, type ErrorCode, type Needs, type SourceSpan } from './errors.js';
2
2
  export type * from './language/ast.js';
3
3
  export { parseSitefile } from './language/parser.js';
4
4
  export { validate, parseProgram } from './language/validate.js';
@@ -9,4 +9,5 @@ export { BuildMemo, type MemoStats } from './memo.js';
9
9
  export { Profiler } from './profile.js';
10
10
  export { Tree, makeFile, type FileNode, type ConflictPolicy } from './tree.js';
11
11
  export { DEFAULT_ARCHIVE_LIMITS, DEFAULT_FETCH_POLICY, type ArchiveLimits, type FetchPolicy, type Resource } from './resources/types.js';
12
+ export { networkFromEnv, mergeNetwork, networkProblem, hostMatches, type NetworkSettings } from './resources/network.js';
12
13
  export { extractArchive } from './resources/archive.js';
package/lib/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SitefileError, isSitefileError, formatSpan } from './errors.js';
1
+ export { SitefileError, isSitefileError, formatSpan, needsPlugin, needsTool } from './errors.js';
2
2
  export { parseSitefile } from './language/parser.js';
3
3
  export { validate, parseProgram } from './language/validate.js';
4
4
  export { plan } from './planner.js';
@@ -8,5 +8,6 @@ export { BuildMemo } from './memo.js';
8
8
  export { Profiler } from './profile.js';
9
9
  export { Tree, makeFile } from './tree.js';
10
10
  export { DEFAULT_ARCHIVE_LIMITS, DEFAULT_FETCH_POLICY } from './resources/types.js';
11
+ export { networkFromEnv, mergeNetwork, networkProblem, hostMatches } from './resources/network.js';
11
12
  export { extractArchive } from './resources/archive.js';
12
13
  //# sourceMappingURL=index.js.map
@@ -201,13 +201,20 @@ export interface ExportInstruction {
201
201
  readonly base?: Operand;
202
202
  /** `AT </srv/www>`: where the files live inside the layer (`oci` only). */
203
203
  readonly at?: Operand;
204
- /** `COMPRESSION gzip|none`: how the site layer is stored (`oci` only; default gzip). */
204
+ /**
205
+ * `COMPRESSION gzip|none` (`oci` and `wbn` only): how the oci site layer is stored (default gzip),
206
+ * or whether the whole Web Bundle is gzipped (default none).
207
+ */
205
208
  readonly compression?: {
206
209
  readonly value: OciCompression;
207
210
  readonly span: SourceSpan;
208
211
  };
209
212
  }
210
- /** OCI site layer encoding: `gzip` (tar+gzip, the default) or `none` (plain tar). */
213
+ /**
214
+ * `EXPORT ... COMPRESSION`: for `oci`, the site layer encoding, `gzip` (tar+gzip, the default) or `none`
215
+ * (plain tar); for `wbn`, `gzip` wraps the bundle in gzip (the family's `.wbn.gz`) and `none` (the default)
216
+ * writes the plain bundle.
217
+ */
211
218
  export type OciCompression = 'gzip' | 'none';
212
219
  export declare const OCI_COMPRESSIONS: readonly OciCompression[];
213
220
  /** Parse a compression name (case-insensitive); undefined if unknown. */
@@ -160,7 +160,7 @@ export async function outdated(options) {
160
160
  return entry({ kind, name, source, pin, pinnedBy, latest: null, status: 'digest-only', newerTag: null, error: null });
161
161
  }
162
162
  const tag = ref.tag ?? 'latest';
163
- const client = makeClient(registry, { ...options.registry, allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork });
163
+ const client = makeClient(registry, { ...options.registry, allowPrivateNetwork: options.registry?.allowPrivateNetwork ?? policy.allowPrivateNetwork, network: options.registry?.network ?? policy.network });
164
164
  const latest = (await client.getManifest(ref.name, tag)).digest;
165
165
  let newerTag = null;
166
166
  if (versionTag(tag)) {
@@ -259,7 +259,7 @@ export interface Plan {
259
259
  readonly exportBase: string | undefined;
260
260
  /** Evaluated `AT` clause of EXPORT ... AS oci: the root directory inside the image layer. */
261
261
  readonly exportRoot: string | undefined;
262
- /** `COMPRESSION` clause of EXPORT ... AS oci (undefined: gzip). */
262
+ /** `COMPRESSION` clause of EXPORT ... AS oci or wbn (undefined: gzip for oci, none for wbn). */
263
263
  readonly exportCompression: OciCompression | undefined;
264
264
  /** REDIRECT lines in source order (any stage); EXPORT writes them to `/_redirects`. */
265
265
  readonly redirects: readonly RedirectSpec[];
@@ -240,8 +240,8 @@ export function plan(program, buildArgs = {}, argSource = (n) => `--build-arg ${
240
240
  if (ins.at && ins.format !== 'oci')
241
241
  throw new SitefileError('E_VALIDATION', 'EXPORT ... AT applies only to AS oci', ins.at.span);
242
242
  exportRoot = ins.at ? normalizeVirtual(evaluateOperand(ins.at, vars), ins.at.span, 'EXPORT AT path').path : undefined;
243
- if (ins.compression && ins.format !== 'oci')
244
- throw new SitefileError('E_VALIDATION', 'EXPORT ... COMPRESSION applies only to AS oci', ins.compression.span);
243
+ if (ins.compression && ins.format !== 'oci' && ins.format !== 'wbn')
244
+ throw new SitefileError('E_VALIDATION', 'EXPORT ... COMPRESSION applies only to AS oci and AS wbn', ins.compression.span);
245
245
  exportCompression = ins.compression?.value;
246
246
  break;
247
247
  }
@@ -2,15 +2,27 @@ import type { PluginResult } from './host.js';
2
2
  /**
3
3
  * Content-addressed cache of TRANSFORM results (see docs/PLUGINS.md, "Caching").
4
4
  *
5
- * <dir>/entries/<key>.json manifest: which blobs make up the result
5
+ * <dir>/entries/<key>.json manifest: which blobs make up the result, and the
6
+ * PLUGIN or TOOL that produced it ("plugin")
6
7
  * <dir>/blobs/<hex> result bytes, named by their SHA-256
7
8
  *
8
9
  * The key is the SHA-256 of a canonical JSON document describing everything the
9
10
  * result can depend on. Reads verify every blob's digest and size; a damaged
10
11
  * entry is removed and counts as a miss, so the next put rewrites it.
12
+ * "plugin" is optional (entries written before it was recorded have none and are
13
+ * still read) and not part of the key.
11
14
  */
12
15
  /** Bump to invalidate every existing entry when the key or entry format changes. */
13
16
  export declare const CACHE_VERSION = 1;
17
+ /** What one plugin's entries hold (`TransformCache.usageByPlugin`). */
18
+ export interface PluginCacheUsage {
19
+ /** The PLUGIN or TOOL name; undefined for entries that record none. */
20
+ readonly plugin: string | undefined;
21
+ readonly entries: number;
22
+ /** Distinct blobs these entries reference (a blob shared with another plugin's entry counts for both). */
23
+ readonly blobs: number;
24
+ readonly bytes: number;
25
+ }
14
26
  /** What an entry records: the result kind and `[path, blob hex, size]` per file (a `file` result has one, with path ''). */
15
27
  export interface CacheEntry {
16
28
  readonly kind: 'file' | 'files';
@@ -48,9 +60,11 @@ export declare class TransformCache {
48
60
  constructor(dir: string, remote?: RemoteCache | undefined);
49
61
  private entryPath;
50
62
  private blobPath;
51
- get(key: string): Promise<PluginResult | undefined>;
63
+ /** `plugin`: the TRANSFORM's PLUGIN or TOOL, recorded on an entry fetched from the remote cache. */
64
+ get(key: string, plugin?: string): Promise<PluginResult | undefined>;
52
65
  private load;
53
- put(key: string, result: PluginResult): Promise<void>;
66
+ /** `plugin`: the PLUGIN or TOOL that produced the result (see `usageByPlugin`, `prunePlugin`). */
67
+ put(key: string, result: PluginResult, plugin?: string): Promise<void>;
54
68
  private writeAtomic;
55
69
  info(): Promise<CacheInfo>;
56
70
  /**
@@ -62,6 +76,18 @@ export declare class TransformCache {
62
76
  blobs: number;
63
77
  bytes: number;
64
78
  }>;
79
+ /**
80
+ * Remove the entries `plugin` (a PLUGIN or TOOL name) wrote that were not used for `olderThanMs`
81
+ * (0: all of them), then the blobs they used that no remaining entry references. Entries of other
82
+ * plugins, entries that record no plugin, and blobs no entry references yet are kept.
83
+ */
84
+ prunePlugin(plugin: string, olderThanMs?: number, now?: number): Promise<{
85
+ entries: number;
86
+ blobs: number;
87
+ bytes: number;
88
+ }>;
89
+ /** Entries, blobs and bytes per plugin, sorted by name (entries that record none first). Reads no blob. */
90
+ usageByPlugin(): Promise<PluginCacheUsage[]>;
65
91
  }
66
92
  /** Digest of every regular file under `dir` (a plugin can read all of them, so any may change its output). */
67
93
  export declare function digestFolder(dir: string, limits?: {