@twango/twiki 0.0.0-stage → 0.1.0-rc.0
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/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- package/site/wrangler.jsonc +37 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { hash } from 'blake3-wasm';
|
|
4
|
+
import ignore from 'ignore';
|
|
5
|
+
import mime from 'mime';
|
|
6
|
+
import { deploymentError, record, sha256 } from './deployment-config';
|
|
7
|
+
|
|
8
|
+
export interface DeploymentAsset {
|
|
9
|
+
hash: string;
|
|
10
|
+
size: number;
|
|
11
|
+
sha256: string;
|
|
12
|
+
contentType: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AssetInventory {
|
|
16
|
+
schema: 1;
|
|
17
|
+
provenance: string;
|
|
18
|
+
complete: true;
|
|
19
|
+
assets: Record<string, DeploymentAsset>;
|
|
20
|
+
controlFiles: { _headers?: string; _redirects?: string };
|
|
21
|
+
digest: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const SHA256 = /^[0-9a-f]{64}$/;
|
|
25
|
+
export const ASSET_HASH = /^[0-9a-f]{32}$/;
|
|
26
|
+
export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
|
|
27
|
+
export const WRANGLER_ASSET_HASH_VERSION = 'wrangler-4.100-blake3-base64-extension-v1';
|
|
28
|
+
|
|
29
|
+
export function assetPath(value: string): boolean {
|
|
30
|
+
return (
|
|
31
|
+
value.startsWith('/') &&
|
|
32
|
+
value.length > 1 &&
|
|
33
|
+
!/[\\\0\r\n]/.test(value) &&
|
|
34
|
+
value
|
|
35
|
+
.slice(1)
|
|
36
|
+
.split('/')
|
|
37
|
+
.every((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function wranglerAssetHash(bytes: Uint8Array, filename: string): string {
|
|
42
|
+
// Matches Wrangler 4.100.0 hashFile: BLAKE3(base64(contents) + extension), first 128 bits.
|
|
43
|
+
return hash(Buffer.from(bytes).toString('base64') + path.extname(filename).slice(1))
|
|
44
|
+
.toString('hex')
|
|
45
|
+
.slice(0, 32);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function assetContentType(filename: string): string {
|
|
49
|
+
const type = mime.getType(filename);
|
|
50
|
+
return type?.startsWith('text/') ? `${type}; charset=utf-8` : (type ?? 'application/null');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function deploymentAsset(bytes: Uint8Array, filename: string): DeploymentAsset {
|
|
54
|
+
if (!assetPath(filename) || bytes.byteLength > MAX_ASSET_BYTES)
|
|
55
|
+
deploymentError('invalid asset product');
|
|
56
|
+
return {
|
|
57
|
+
hash: wranglerAssetHash(bytes, filename),
|
|
58
|
+
size: bytes.byteLength,
|
|
59
|
+
sha256: sha256(bytes),
|
|
60
|
+
contentType: assetContentType(filename)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createAssetInventory(
|
|
65
|
+
assets: Record<string, DeploymentAsset>,
|
|
66
|
+
provenance: string,
|
|
67
|
+
controlFiles: AssetInventory['controlFiles'] = {}
|
|
68
|
+
): AssetInventory {
|
|
69
|
+
const inventory: AssetInventory = {
|
|
70
|
+
schema: 1,
|
|
71
|
+
complete: true,
|
|
72
|
+
provenance,
|
|
73
|
+
assets,
|
|
74
|
+
controlFiles,
|
|
75
|
+
digest: ''
|
|
76
|
+
};
|
|
77
|
+
inventory.digest = inventoryDigest(inventory);
|
|
78
|
+
return validateAssetInventory(inventory, provenance);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VerifiedInventoryMerge {
|
|
82
|
+
pieces: { owner: string; inventory: AssetInventory }[];
|
|
83
|
+
/** Exact identity and inventory digest from the current authenticated product manifest. */
|
|
84
|
+
expectedProducts: Record<string, { provenance: string; digest: string }>;
|
|
85
|
+
/** Complete current output ledger, including precedence decisions for overlapping products. */
|
|
86
|
+
ownership: Record<string, { owner: string; sha256: string; size: number }>;
|
|
87
|
+
provenance: string;
|
|
88
|
+
controlFiles: AssetInventory['controlFiles'];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reuse hashes only from verified product inventories, never from file timestamps alone.
|
|
93
|
+
* The caller must authenticate expectedProducts and the complete current ownership ledger.
|
|
94
|
+
* Missing/revoked products and stale byte identities fail closed. No filesystem reads occur.
|
|
95
|
+
*/
|
|
96
|
+
export function mergeVerifiedInventories(input: VerifiedInventoryMerge): AssetInventory {
|
|
97
|
+
const products = new Map<string, AssetInventory>();
|
|
98
|
+
for (const { owner, inventory } of input.pieces) {
|
|
99
|
+
const expected = input.expectedProducts[owner];
|
|
100
|
+
if (!expected || products.has(owner) || inventory.digest !== expected.digest)
|
|
101
|
+
deploymentError('unexpected product inventory');
|
|
102
|
+
products.set(owner, validateAssetInventory(inventory, expected.provenance));
|
|
103
|
+
}
|
|
104
|
+
if (products.size !== Object.keys(input.expectedProducts).length)
|
|
105
|
+
deploymentError('missing product inventory');
|
|
106
|
+
const assets: Record<string, DeploymentAsset> = Object.create(null);
|
|
107
|
+
for (const [filename, source] of Object.entries(input.ownership)) {
|
|
108
|
+
if (!assetPath(filename) || !SHA256.test(source.sha256) || !Number.isSafeInteger(source.size))
|
|
109
|
+
deploymentError('invalid ownership ledger');
|
|
110
|
+
const asset = products.get(source.owner)?.assets[filename];
|
|
111
|
+
if (!asset || asset.sha256 !== source.sha256 || asset.size !== source.size)
|
|
112
|
+
deploymentError('product inventory does not cover current output');
|
|
113
|
+
assets[filename] = asset;
|
|
114
|
+
}
|
|
115
|
+
return createAssetInventory(assets, input.provenance, input.controlFiles);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function inventoryDigest(
|
|
119
|
+
inventory: Pick<AssetInventory, 'provenance' | 'assets' | 'controlFiles'>
|
|
120
|
+
): string {
|
|
121
|
+
return sha256(
|
|
122
|
+
JSON.stringify([
|
|
123
|
+
inventory.provenance,
|
|
124
|
+
Object.entries(inventory.assets).sort(([a], [b]) => a.localeCompare(b)),
|
|
125
|
+
inventory.controlFiles._headers ?? null,
|
|
126
|
+
inventory.controlFiles._redirects ?? null
|
|
127
|
+
])
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function validateAssetInventory(value: unknown, expectedProvenance: string): AssetInventory {
|
|
132
|
+
if (
|
|
133
|
+
!record(value) ||
|
|
134
|
+
value.schema !== 1 ||
|
|
135
|
+
value.complete !== true ||
|
|
136
|
+
value.provenance !== expectedProvenance ||
|
|
137
|
+
!SHA256.test(expectedProvenance) ||
|
|
138
|
+
!record(value.assets) ||
|
|
139
|
+
!record(value.controlFiles)
|
|
140
|
+
)
|
|
141
|
+
deploymentError('stale or incomplete asset inventory');
|
|
142
|
+
if (
|
|
143
|
+
Object.keys(value.controlFiles).some((key) => !['_headers', '_redirects'].includes(key)) ||
|
|
144
|
+
Object.values(value.controlFiles).some((v) => typeof v !== 'string')
|
|
145
|
+
)
|
|
146
|
+
deploymentError('invalid asset control files');
|
|
147
|
+
for (const [filename, asset] of Object.entries(value.assets)) {
|
|
148
|
+
if (
|
|
149
|
+
!assetPath(filename) ||
|
|
150
|
+
!record(asset) ||
|
|
151
|
+
typeof asset.hash !== 'string' ||
|
|
152
|
+
!ASSET_HASH.test(asset.hash) ||
|
|
153
|
+
typeof asset.sha256 !== 'string' ||
|
|
154
|
+
!SHA256.test(asset.sha256) ||
|
|
155
|
+
!Number.isSafeInteger(asset.size) ||
|
|
156
|
+
(asset.size as number) < 0 ||
|
|
157
|
+
(asset.size as number) > MAX_ASSET_BYTES ||
|
|
158
|
+
typeof asset.contentType !== 'string' ||
|
|
159
|
+
/[\r\n]/.test(asset.contentType) ||
|
|
160
|
+
asset.contentType !== assetContentType(filename)
|
|
161
|
+
)
|
|
162
|
+
deploymentError('invalid asset inventory');
|
|
163
|
+
if (
|
|
164
|
+
filename === '/_headers' ||
|
|
165
|
+
filename === '/_redirects' ||
|
|
166
|
+
filename === '/.assetsignore' ||
|
|
167
|
+
filename === '/_worker.js'
|
|
168
|
+
)
|
|
169
|
+
deploymentError('invalid asset inventory control entry');
|
|
170
|
+
}
|
|
171
|
+
const inventory = value as unknown as AssetInventory;
|
|
172
|
+
if (inventory.digest !== inventoryDigest(inventory))
|
|
173
|
+
deploymentError('asset inventory integrity failed');
|
|
174
|
+
return inventory;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function verifyAssetBytes(
|
|
178
|
+
filename: string,
|
|
179
|
+
asset: DeploymentAsset,
|
|
180
|
+
bytes: Uint8Array
|
|
181
|
+
): void {
|
|
182
|
+
if (
|
|
183
|
+
bytes.byteLength !== asset.size ||
|
|
184
|
+
sha256(bytes) !== asset.sha256 ||
|
|
185
|
+
wranglerAssetHash(bytes, filename) !== asset.hash
|
|
186
|
+
)
|
|
187
|
+
deploymentError('asset body integrity failed');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function currentDescriptorProvenance(bytes: Uint8Array): string {
|
|
191
|
+
let descriptor: unknown;
|
|
192
|
+
try {
|
|
193
|
+
descriptor = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
194
|
+
} catch {
|
|
195
|
+
deploymentError('invalid current content descriptor');
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
!record(descriptor) ||
|
|
199
|
+
descriptor.schemaVersion !== 1 ||
|
|
200
|
+
typeof descriptor.applicationVersion !== 'string' ||
|
|
201
|
+
!SHA256.test(descriptor.applicationVersion) ||
|
|
202
|
+
!record(descriptor.snapshots) ||
|
|
203
|
+
typeof descriptor.snapshots.public !== 'string' ||
|
|
204
|
+
!SHA256.test(descriptor.snapshots.public) ||
|
|
205
|
+
typeof descriptor.snapshots.full !== 'string' ||
|
|
206
|
+
!SHA256.test(descriptor.snapshots.full)
|
|
207
|
+
)
|
|
208
|
+
deploymentError('invalid current content descriptor');
|
|
209
|
+
return sha256(bytes);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function readCurrentDescriptorProvenance(assetDirectory: string): Promise<string> {
|
|
213
|
+
return currentDescriptorProvenance(
|
|
214
|
+
await readFile(path.join(assetDirectory, '_content/current.json'))
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Always hashes actual bytes. A restored authenticated inventory may be reused by its product provenance. */
|
|
219
|
+
export async function captureAssetInventory(
|
|
220
|
+
assetDirectory: string,
|
|
221
|
+
provenance: string,
|
|
222
|
+
options: {
|
|
223
|
+
resolveAsset?: (absoluteFilename: string, assetPath: string) => Promise<DeploymentAsset>;
|
|
224
|
+
} = {}
|
|
225
|
+
): Promise<AssetInventory> {
|
|
226
|
+
if (!SHA256.test(provenance)) deploymentError('invalid inventory provenance');
|
|
227
|
+
const controlFiles: AssetInventory['controlFiles'] = {};
|
|
228
|
+
for (const filename of ['_headers', '_redirects'] as const) {
|
|
229
|
+
try {
|
|
230
|
+
if (!(await lstat(path.join(assetDirectory, filename))).isFile())
|
|
231
|
+
deploymentError('invalid asset control file');
|
|
232
|
+
controlFiles[filename] = await readFile(path.join(assetDirectory, filename), 'utf8');
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let patterns = '';
|
|
238
|
+
try {
|
|
239
|
+
if (!(await lstat(path.join(assetDirectory, '.assetsignore'))).isFile())
|
|
240
|
+
deploymentError('invalid asset ignore file');
|
|
241
|
+
patterns = await readFile(path.join(assetDirectory, '.assetsignore'), 'utf8');
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
244
|
+
}
|
|
245
|
+
const ignorer = ignore().add([
|
|
246
|
+
'/.assetsignore',
|
|
247
|
+
'/_headers',
|
|
248
|
+
'/_redirects',
|
|
249
|
+
...patterns.split('\n')
|
|
250
|
+
]);
|
|
251
|
+
const assets: Record<string, DeploymentAsset> = Object.create(null);
|
|
252
|
+
async function visit(directory: string, prefix: string): Promise<void> {
|
|
253
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
254
|
+
const relative = `${prefix}${entry.name}`;
|
|
255
|
+
if (ignorer.test(relative).ignored) continue;
|
|
256
|
+
if (entry.isSymbolicLink()) deploymentError('asset symlinks are unsupported');
|
|
257
|
+
if (entry.isDirectory()) await visit(path.join(directory, entry.name), `${relative}/`);
|
|
258
|
+
else if (entry.isFile()) {
|
|
259
|
+
const filename = `/${relative}`;
|
|
260
|
+
if (!assetPath(filename) || relative === '_worker.js')
|
|
261
|
+
deploymentError('invalid asset path');
|
|
262
|
+
if (options.resolveAsset)
|
|
263
|
+
assets[filename] = await options.resolveAsset(path.join(directory, entry.name), filename);
|
|
264
|
+
else
|
|
265
|
+
assets[filename] = deploymentAsset(
|
|
266
|
+
await readFile(path.join(directory, entry.name)),
|
|
267
|
+
filename
|
|
268
|
+
);
|
|
269
|
+
} else deploymentError('unsupported asset file');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
await visit(assetDirectory, '');
|
|
273
|
+
return createAssetInventory(assets, provenance, controlFiles);
|
|
274
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
configSha256,
|
|
6
|
+
deploymentError,
|
|
7
|
+
equalJSON,
|
|
8
|
+
expectedBindings,
|
|
9
|
+
readDeploymentConfig,
|
|
10
|
+
record,
|
|
11
|
+
sha256,
|
|
12
|
+
type DeploymentConfig
|
|
13
|
+
} from './deployment-config';
|
|
14
|
+
import {
|
|
15
|
+
SHA256,
|
|
16
|
+
assetPath,
|
|
17
|
+
validateAssetInventory,
|
|
18
|
+
type AssetInventory
|
|
19
|
+
} from './deployment-manifest';
|
|
20
|
+
|
|
21
|
+
export interface PreparedModule {
|
|
22
|
+
name: string;
|
|
23
|
+
type: string;
|
|
24
|
+
sha256: string;
|
|
25
|
+
size: number;
|
|
26
|
+
filename: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PreparedWorker {
|
|
30
|
+
schema: 1;
|
|
31
|
+
configSha256: string;
|
|
32
|
+
wranglerVersion: string;
|
|
33
|
+
metadata: Record<string, unknown>;
|
|
34
|
+
modules: PreparedModule[];
|
|
35
|
+
digest: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DeploymentSnapshot {
|
|
39
|
+
schema: 1;
|
|
40
|
+
provenance: string;
|
|
41
|
+
configSha256: string;
|
|
42
|
+
worker: PreparedWorker;
|
|
43
|
+
inventory: AssetInventory;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function workerDigest(worker: Omit<PreparedWorker, 'digest'> | PreparedWorker): string {
|
|
47
|
+
return sha256(
|
|
48
|
+
JSON.stringify([
|
|
49
|
+
worker.schema,
|
|
50
|
+
worker.configSha256,
|
|
51
|
+
worker.wranglerVersion,
|
|
52
|
+
worker.metadata,
|
|
53
|
+
worker.modules
|
|
54
|
+
])
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validatePreparedWorker(value: unknown, config: DeploymentConfig): PreparedWorker {
|
|
59
|
+
if (
|
|
60
|
+
!record(value) ||
|
|
61
|
+
value.schema !== 1 ||
|
|
62
|
+
value.configSha256 !== configSha256(config) ||
|
|
63
|
+
typeof value.wranglerVersion !== 'string' ||
|
|
64
|
+
!record(value.metadata) ||
|
|
65
|
+
!Array.isArray(value.modules)
|
|
66
|
+
)
|
|
67
|
+
deploymentError('incompatible prepared Worker');
|
|
68
|
+
const metadata = value.metadata;
|
|
69
|
+
if (
|
|
70
|
+
typeof metadata.main_module !== 'string' ||
|
|
71
|
+
!equalJSON(metadata.compatibility_flags, config.compatibility_flags) ||
|
|
72
|
+
metadata.compatibility_date !== config.compatibility_date ||
|
|
73
|
+
!Array.isArray(metadata.bindings)
|
|
74
|
+
)
|
|
75
|
+
deploymentError('prepared Worker metadata mismatch');
|
|
76
|
+
const bindings = [...metadata.bindings].sort((a, b) =>
|
|
77
|
+
String(a.name).localeCompare(String(b.name))
|
|
78
|
+
);
|
|
79
|
+
if (
|
|
80
|
+
!equalJSON(
|
|
81
|
+
bindings,
|
|
82
|
+
expectedBindings(config).sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
deploymentError('prepared Worker binding mismatch');
|
|
86
|
+
if (!equalJSON(metadata.keep_bindings, ['secret_text', 'secret_key']))
|
|
87
|
+
deploymentError('prepared Worker must preserve secrets');
|
|
88
|
+
const supported = [
|
|
89
|
+
'main_module',
|
|
90
|
+
'bindings',
|
|
91
|
+
'compatibility_date',
|
|
92
|
+
'compatibility_flags',
|
|
93
|
+
'keep_bindings',
|
|
94
|
+
'logpush',
|
|
95
|
+
'observability'
|
|
96
|
+
];
|
|
97
|
+
if (
|
|
98
|
+
Object.keys(metadata).some((key) => !supported.includes(key)) ||
|
|
99
|
+
(metadata.logpush !== undefined && metadata.logpush !== false)
|
|
100
|
+
)
|
|
101
|
+
deploymentError('unsupported prepared Worker metadata');
|
|
102
|
+
// Wrangler normalizes observability even when it is absent from the source configuration.
|
|
103
|
+
if (
|
|
104
|
+
metadata.observability !== undefined &&
|
|
105
|
+
(!record(metadata.observability) ||
|
|
106
|
+
metadata.observability.enabled !== false ||
|
|
107
|
+
Object.keys(metadata.observability).some((key) => key !== 'enabled'))
|
|
108
|
+
)
|
|
109
|
+
deploymentError('unsupported prepared Worker observability');
|
|
110
|
+
const names = new Set<string>();
|
|
111
|
+
for (const module of value.modules) {
|
|
112
|
+
if (
|
|
113
|
+
!record(module) ||
|
|
114
|
+
typeof module.name !== 'string' ||
|
|
115
|
+
!assetPath(`/${module.name}`) ||
|
|
116
|
+
names.has(module.name) ||
|
|
117
|
+
typeof module.sha256 !== 'string' ||
|
|
118
|
+
!SHA256.test(module.sha256) ||
|
|
119
|
+
module.filename !== `modules/${module.sha256}` ||
|
|
120
|
+
!Number.isSafeInteger(module.size) ||
|
|
121
|
+
(module.size as number) < 0 ||
|
|
122
|
+
![
|
|
123
|
+
'application/javascript+module',
|
|
124
|
+
'application/javascript',
|
|
125
|
+
'text/plain',
|
|
126
|
+
'application/wasm',
|
|
127
|
+
'application/octet-stream',
|
|
128
|
+
'application/source-map'
|
|
129
|
+
].includes(module.type as string)
|
|
130
|
+
)
|
|
131
|
+
deploymentError('invalid prepared Worker module');
|
|
132
|
+
names.add(module.name);
|
|
133
|
+
}
|
|
134
|
+
if (!names.has(metadata.main_module) || !value.modules.length)
|
|
135
|
+
deploymentError('prepared Worker entry module is missing');
|
|
136
|
+
const worker = value as unknown as PreparedWorker;
|
|
137
|
+
if (worker.digest !== workerDigest(worker)) deploymentError('prepared Worker integrity failed');
|
|
138
|
+
return worker;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse Wrangler's actual serialized upload form; no module/default dependency inference. */
|
|
142
|
+
export async function capturePreparedWorker(
|
|
143
|
+
multipart: Uint8Array,
|
|
144
|
+
config: DeploymentConfig,
|
|
145
|
+
version: string,
|
|
146
|
+
outputDirectory: string
|
|
147
|
+
): Promise<PreparedWorker> {
|
|
148
|
+
const firstLine = Buffer.from(multipart).subarray(0, 200).toString().split('\r\n')[0];
|
|
149
|
+
if (!/^--[-a-zA-Z0-9]+$/.test(firstLine)) deploymentError('invalid Wrangler multipart boundary');
|
|
150
|
+
// Bun's multipart parser rewrites .js part MIME types. Preserve Wrangler's exact raw headers.
|
|
151
|
+
const buffer = Buffer.from(multipart);
|
|
152
|
+
const boundary = Buffer.from(`\r\n${firstLine}`);
|
|
153
|
+
const parts: { name: string; type: string; bytes: Uint8Array }[] = [];
|
|
154
|
+
let cursor = firstLine.length + 2;
|
|
155
|
+
let closed = false;
|
|
156
|
+
while (cursor < buffer.length) {
|
|
157
|
+
const headersEnd = buffer.indexOf('\r\n\r\n', cursor);
|
|
158
|
+
if (headersEnd < 0 || headersEnd - cursor > 16_384)
|
|
159
|
+
deploymentError('invalid Wrangler multipart headers');
|
|
160
|
+
const headers = buffer.subarray(cursor, headersEnd).toString('utf8');
|
|
161
|
+
const disposition = headers.split('\r\n').find((line) => /^Content-Disposition: /i.test(line));
|
|
162
|
+
const nameMatch = disposition?.match(/; name=("(?:[^"\\]|\\.)*")/);
|
|
163
|
+
if (!nameMatch) deploymentError('invalid Wrangler multipart disposition');
|
|
164
|
+
let name: string;
|
|
165
|
+
try {
|
|
166
|
+
name = JSON.parse(nameMatch[1]);
|
|
167
|
+
} catch {
|
|
168
|
+
deploymentError('invalid Wrangler module name');
|
|
169
|
+
}
|
|
170
|
+
const type =
|
|
171
|
+
headers
|
|
172
|
+
.split('\r\n')
|
|
173
|
+
.find((line) => /^Content-Type: /i.test(line))
|
|
174
|
+
?.slice('Content-Type: '.length) ?? '';
|
|
175
|
+
const bodyStart = headersEnd + 4;
|
|
176
|
+
const bodyEnd = buffer.indexOf(boundary, bodyStart);
|
|
177
|
+
if (bodyEnd < 0) deploymentError('invalid Wrangler multipart body');
|
|
178
|
+
parts.push({ name, type, bytes: buffer.subarray(bodyStart, bodyEnd) });
|
|
179
|
+
cursor = bodyEnd + boundary.length;
|
|
180
|
+
if (buffer.subarray(cursor, cursor + 2).toString() === '--') {
|
|
181
|
+
if (!/^--(?:\r\n)?$/.test(buffer.subarray(cursor).toString()))
|
|
182
|
+
deploymentError('invalid Wrangler multipart trailer');
|
|
183
|
+
closed = true;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (buffer.subarray(cursor, cursor + 2).toString() !== '\r\n')
|
|
187
|
+
deploymentError('invalid Wrangler multipart separator');
|
|
188
|
+
cursor += 2;
|
|
189
|
+
}
|
|
190
|
+
if (!closed || parts.filter((part) => part.name === 'metadata').length !== 1)
|
|
191
|
+
deploymentError('Wrangler metadata is missing');
|
|
192
|
+
const rawMetadata = Buffer.from(parts.find((part) => part.name === 'metadata')!.bytes).toString(
|
|
193
|
+
'utf8'
|
|
194
|
+
);
|
|
195
|
+
let metadata: Record<string, unknown>;
|
|
196
|
+
try {
|
|
197
|
+
metadata = JSON.parse(rawMetadata);
|
|
198
|
+
} catch {
|
|
199
|
+
deploymentError('invalid Wrangler metadata');
|
|
200
|
+
}
|
|
201
|
+
if (!record(metadata) || !Array.isArray(metadata.bindings))
|
|
202
|
+
deploymentError('invalid Wrangler metadata');
|
|
203
|
+
metadata.keep_bindings = ['secret_text', 'secret_key'];
|
|
204
|
+
const modules: PreparedModule[] = [];
|
|
205
|
+
const bodies = new Map<string, Uint8Array>();
|
|
206
|
+
for (const { name, type, bytes } of parts) {
|
|
207
|
+
if (name === 'metadata') continue;
|
|
208
|
+
const digest = sha256(bytes);
|
|
209
|
+
modules.push({
|
|
210
|
+
name,
|
|
211
|
+
type,
|
|
212
|
+
sha256: digest,
|
|
213
|
+
size: bytes.byteLength,
|
|
214
|
+
filename: `modules/${digest}`
|
|
215
|
+
});
|
|
216
|
+
bodies.set(digest, bytes);
|
|
217
|
+
}
|
|
218
|
+
const worker: PreparedWorker = {
|
|
219
|
+
schema: 1,
|
|
220
|
+
configSha256: configSha256(config),
|
|
221
|
+
wranglerVersion: version,
|
|
222
|
+
metadata,
|
|
223
|
+
modules,
|
|
224
|
+
digest: ''
|
|
225
|
+
};
|
|
226
|
+
worker.digest = workerDigest(worker);
|
|
227
|
+
validatePreparedWorker(worker, config);
|
|
228
|
+
await mkdir(path.join(outputDirectory, 'modules'), { recursive: true });
|
|
229
|
+
for (const [digest, body] of bodies)
|
|
230
|
+
await writeFile(path.join(outputDirectory, 'modules', digest), body, { mode: 0o600 });
|
|
231
|
+
await writeFile(path.join(outputDirectory, 'worker.json'), JSON.stringify(worker), {
|
|
232
|
+
mode: 0o600
|
|
233
|
+
});
|
|
234
|
+
return worker;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function verifyModuleBytes(module: PreparedModule, bytes: Uint8Array): void {
|
|
238
|
+
if (bytes.byteLength !== module.size || sha256(bytes) !== module.sha256)
|
|
239
|
+
deploymentError('prepared Worker module integrity failed');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function loadPreparedWorker(
|
|
243
|
+
outputDirectory: string,
|
|
244
|
+
config?: DeploymentConfig
|
|
245
|
+
): Promise<PreparedWorker> {
|
|
246
|
+
const worker: PreparedWorker = JSON.parse(
|
|
247
|
+
await readFile(path.join(outputDirectory, 'worker.json'), 'utf8')
|
|
248
|
+
);
|
|
249
|
+
// Callers normally supply config. Derivation from the site directory is only a convenience for canonical layout.
|
|
250
|
+
validatePreparedWorker(
|
|
251
|
+
worker,
|
|
252
|
+
config ?? readDeploymentConfig(path.resolve(outputDirectory, '../../..'))
|
|
253
|
+
);
|
|
254
|
+
for (const module of worker.modules)
|
|
255
|
+
verifyModuleBytes(module, await readFile(path.join(outputDirectory, module.filename)));
|
|
256
|
+
return worker;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Offline, credential-free Wrangler build. Does not register/upload assets or call account APIs. */
|
|
260
|
+
export async function prepareWorker(
|
|
261
|
+
siteRoot: string,
|
|
262
|
+
outputDirectory: string
|
|
263
|
+
): Promise<PreparedWorker> {
|
|
264
|
+
siteRoot = path.resolve(siteRoot);
|
|
265
|
+
outputDirectory = path.resolve(outputDirectory);
|
|
266
|
+
const config = readDeploymentConfig(siteRoot);
|
|
267
|
+
const version = JSON.parse(
|
|
268
|
+
await readFile(path.join(siteRoot, 'node_modules/wrangler/package.json'), 'utf8')
|
|
269
|
+
).version;
|
|
270
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
271
|
+
const offlineConfig = {
|
|
272
|
+
...config,
|
|
273
|
+
main: path.resolve(siteRoot, config.main),
|
|
274
|
+
assets: { ...config.assets, directory: path.resolve(siteRoot, config.assets.directory) }
|
|
275
|
+
};
|
|
276
|
+
delete offlineConfig.account_id;
|
|
277
|
+
// Do not load the site's dotenv files or inherit Cloudflare credentials into Wrangler's process.
|
|
278
|
+
const configFile = path.join(outputDirectory, 'offline-wrangler.json');
|
|
279
|
+
const multipartFile = path.join(outputDirectory, 'worker.multipart');
|
|
280
|
+
await writeFile(configFile, JSON.stringify(offlineConfig), { mode: 0o600 });
|
|
281
|
+
const env = Object.fromEntries(
|
|
282
|
+
Object.entries(process.env).filter(([key]) => !/^(CLOUDFLARE_|CF_|WRANGLER_)/.test(key))
|
|
283
|
+
);
|
|
284
|
+
env.WRANGLER_SEND_METRICS = 'false';
|
|
285
|
+
env.WRANGLER_LOG = 'error';
|
|
286
|
+
env.WRANGLER_LOG_PATH = path.join(outputDirectory, 'wrangler.log');
|
|
287
|
+
const result = spawnSync(
|
|
288
|
+
process.env.NODE_BINARY ?? 'node',
|
|
289
|
+
[
|
|
290
|
+
path.join(siteRoot, 'node_modules/wrangler/bin/wrangler.js'),
|
|
291
|
+
'deploy',
|
|
292
|
+
'--config',
|
|
293
|
+
configFile,
|
|
294
|
+
'--dry-run',
|
|
295
|
+
'--outdir',
|
|
296
|
+
path.join(outputDirectory, 'wrangler'),
|
|
297
|
+
'--outfile',
|
|
298
|
+
multipartFile
|
|
299
|
+
],
|
|
300
|
+
{ cwd: outputDirectory, env, encoding: 'utf8', maxBuffer: 1024 * 1024 * 8 }
|
|
301
|
+
);
|
|
302
|
+
try {
|
|
303
|
+
if (result.status !== 0) deploymentError('offline Wrangler preparation failed');
|
|
304
|
+
return await capturePreparedWorker(
|
|
305
|
+
await readFile(multipartFile),
|
|
306
|
+
config,
|
|
307
|
+
version,
|
|
308
|
+
outputDirectory
|
|
309
|
+
);
|
|
310
|
+
} finally {
|
|
311
|
+
await rm(configFile, { force: true });
|
|
312
|
+
await rm(path.join(outputDirectory, 'wrangler.log'), { force: true });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function buildDeploymentSnapshot(
|
|
317
|
+
worker: PreparedWorker,
|
|
318
|
+
inventory: AssetInventory,
|
|
319
|
+
config: DeploymentConfig,
|
|
320
|
+
provenance: string
|
|
321
|
+
): DeploymentSnapshot {
|
|
322
|
+
validatePreparedWorker(worker, config);
|
|
323
|
+
validateAssetInventory(inventory, provenance);
|
|
324
|
+
return { schema: 1, configSha256: configSha256(config), provenance, worker, inventory };
|
|
325
|
+
}
|