@twango/twiki 0.0.0-stage → 0.1.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 (225) hide show
  1. package/.github/actions/product-cache/action.yml +32 -0
  2. package/.github/actions/product-cache/index.mjs +450 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/bun.lock +1701 -0
  6. package/cli/runtime.test.ts +420 -0
  7. package/cli/runtime.ts +385 -0
  8. package/cli/twiki.ts +305 -0
  9. package/package.json +138 -4
  10. package/site/_headers +7 -0
  11. package/site/assets/fonts/OFL.txt +93 -0
  12. package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  13. package/site/assets/social-background.svg +38 -0
  14. package/site/bun.lock +1701 -0
  15. package/site/database/index.ts +7 -0
  16. package/site/database/schema.ts +95 -0
  17. package/site/drizzle.config.ts +7 -0
  18. package/site/migrations/0001_auth.sql +14 -0
  19. package/site/migrations/0002_site_access.sql +5 -0
  20. package/site/migrations/0003_github_username.sql +1 -0
  21. package/site/migrations/meta/0002_snapshot.json +432 -0
  22. package/site/migrations/meta/0003_snapshot.json +439 -0
  23. package/site/migrations/meta/_journal.json +27 -0
  24. package/site/package.json +120 -0
  25. package/site/src/app.d.ts +14 -0
  26. package/site/src/app.html +11 -0
  27. package/site/src/hooks.server.ts +12 -0
  28. package/site/src/lib/analytics-config.json +1 -0
  29. package/site/src/lib/analytics.ts +40 -0
  30. package/site/src/lib/auth-config.json +3 -0
  31. package/site/src/lib/components/SEO.svelte +113 -0
  32. package/site/src/lib/components/doc/AccessLink.svelte +27 -0
  33. package/site/src/lib/components/doc/Backlinks.svelte +59 -0
  34. package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
  35. package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
  36. package/site/src/lib/components/doc/DocPager.svelte +91 -0
  37. package/site/src/lib/components/doc/DocShell.svelte +115 -0
  38. package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
  39. package/site/src/lib/components/doc/LockedContent.svelte +29 -0
  40. package/site/src/lib/components/doc/NavTree.svelte +158 -0
  41. package/site/src/lib/components/doc/NoteActions.svelte +139 -0
  42. package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
  43. package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
  44. package/site/src/lib/components/doc/Sidebar.svelte +351 -0
  45. package/site/src/lib/components/doc/Toc.svelte +339 -0
  46. package/site/src/lib/components/doc/enhancements.ts +146 -0
  47. package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
  48. package/site/src/lib/components/doc/image-preview.ts +114 -0
  49. package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
  50. package/site/src/lib/components/embeds/mermaid.ts +353 -0
  51. package/site/src/lib/components/files/FileCode.svelte +81 -0
  52. package/site/src/lib/components/files/FileExplorer.svelte +74 -0
  53. package/site/src/lib/components/files/FileHistory.svelte +170 -0
  54. package/site/src/lib/components/files/FileIcon.svelte +28 -0
  55. package/site/src/lib/components/files/FileRmd.svelte +209 -0
  56. package/site/src/lib/components/files/FileTabs.svelte +180 -0
  57. package/site/src/lib/components/files/FileTree.svelte +67 -0
  58. package/site/src/lib/components/files/blame-gutter.ts +45 -0
  59. package/site/src/lib/components/files/code-view.ts +98 -0
  60. package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
  61. package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
  62. package/site/src/lib/components/graph/GraphView.svelte +100 -0
  63. package/site/src/lib/components/graph/graph-data.ts +137 -0
  64. package/site/src/lib/components/graph/graph-model.ts +103 -0
  65. package/site/src/lib/components/graph/index.ts +3 -0
  66. package/site/src/lib/components/graph/render-graph.ts +626 -0
  67. package/site/src/lib/components/popover/index.ts +1 -0
  68. package/site/src/lib/components/popover/link-popovers.ts +233 -0
  69. package/site/src/lib/components/popover/popover.css +82 -0
  70. package/site/src/lib/components/search/SearchButton.svelte +57 -0
  71. package/site/src/lib/components/search/SearchPalette.svelte +189 -0
  72. package/site/src/lib/components/search/index.ts +3 -0
  73. package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
  74. package/site/src/lib/components/search/pagefind-query.ts +22 -0
  75. package/site/src/lib/components/search/pagefind-url.ts +12 -0
  76. package/site/src/lib/components/search/search-state.svelte.ts +22 -0
  77. package/site/src/lib/components/ui/IconButton.svelte +30 -0
  78. package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
  79. package/site/src/lib/components/ui/index.ts +1 -0
  80. package/site/src/lib/config.ts +9 -0
  81. package/site/src/lib/content-license.ts +7 -0
  82. package/site/src/lib/file-history.ts +37 -0
  83. package/site/src/lib/file-icons.ts +27 -0
  84. package/site/src/lib/file-tabs.ts +72 -0
  85. package/site/src/lib/files.ts +73 -0
  86. package/site/src/lib/github-user.ts +70 -0
  87. package/site/src/lib/metadata.ts +166 -0
  88. package/site/src/lib/notebook-nav.ts +63 -0
  89. package/site/src/lib/publication.ts +1 -0
  90. package/site/src/lib/rmd.ts +156 -0
  91. package/site/src/lib/server/access-admin.ts +33 -0
  92. package/site/src/lib/server/content-model.ts +253 -0
  93. package/site/src/lib/server/content-provider.ts +266 -0
  94. package/site/src/lib/server/content.ts +277 -0
  95. package/site/src/lib/server/runtime-content.ts +34 -0
  96. package/site/src/lib/site-config.json +9 -0
  97. package/site/src/lib/social-image.ts +13 -0
  98. package/site/src/lib/styles/article.css +607 -0
  99. package/site/src/lib/styles/foundation.css +138 -0
  100. package/site/src/lib/styles/print.css +115 -0
  101. package/site/src/lib/styles/shell.css +182 -0
  102. package/site/src/lib/text-exports.ts +5 -0
  103. package/site/src/lib/types.ts +140 -0
  104. package/site/src/lib/utils.ts +6 -0
  105. package/site/src/routes/+error.svelte +16 -0
  106. package/site/src/routes/+layout.server.ts +10 -0
  107. package/site/src/routes/+layout.svelte +72 -0
  108. package/site/src/routes/+layout.ts +3 -0
  109. package/site/src/routes/404/+page.svelte +13 -0
  110. package/site/src/routes/[...slug]/+page.server.ts +24 -0
  111. package/site/src/routes/[...slug]/+page.svelte +180 -0
  112. package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
  113. package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
  114. package/site/src/routes/admin/access/+page.server.ts +53 -0
  115. package/site/src/routes/admin/access/+page.svelte +118 -0
  116. package/site/src/routes/graph.json/+server.ts +7 -0
  117. package/site/src/routes/index.xml/+server.ts +7 -0
  118. package/site/src/routes/layout.css +10 -0
  119. package/site/src/routes/robots.txt/+server.ts +9 -0
  120. package/site/src/routes/sitemap.xml/+server.ts +7 -0
  121. package/site/src/routes/tags/+page.server.ts +5 -0
  122. package/site/src/routes/tags/+page.svelte +38 -0
  123. package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
  124. package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
  125. package/site/static/favicon.png +0 -0
  126. package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
  127. package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
  128. package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
  129. package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
  130. package/site/static/fonts/Virgil.woff2 +0 -0
  131. package/site/svelte.config.js +71 -0
  132. package/site/tooling/LICENSE-quartz +25 -0
  133. package/site/tooling/adapter.js +55 -0
  134. package/site/tooling/assets.ts +323 -0
  135. package/site/tooling/benchmark.ts +191 -0
  136. package/site/tooling/build.ts +407 -0
  137. package/site/tooling/cache.ts +133 -0
  138. package/site/tooling/check-diagrams.ts +164 -0
  139. package/site/tooling/check-links.ts +86 -0
  140. package/site/tooling/check-targets.ts +139 -0
  141. package/site/tooling/compiler.ts +1386 -0
  142. package/site/tooling/deploy.ts +112 -0
  143. package/site/tooling/fingerprint.ts +81 -0
  144. package/site/tooling/lib/app-version.js +92 -0
  145. package/site/tooling/lib/application-asset-urls.ts +27 -0
  146. package/site/tooling/lib/application-cache.ts +269 -0
  147. package/site/tooling/lib/asset-hash-cache.ts +116 -0
  148. package/site/tooling/lib/autotag.ts +99 -0
  149. package/site/tooling/lib/build-assembly.ts +148 -0
  150. package/site/tooling/lib/build-workspace.ts +386 -0
  151. package/site/tooling/lib/cache-selection.ts +70 -0
  152. package/site/tooling/lib/cloudflare-prewarm.js +40 -0
  153. package/site/tooling/lib/comments.ts +32 -0
  154. package/site/tooling/lib/content-cache.ts +244 -0
  155. package/site/tooling/lib/content-urls.ts +50 -0
  156. package/site/tooling/lib/course-cache.ts +298 -0
  157. package/site/tooling/lib/course-files.ts +281 -0
  158. package/site/tooling/lib/currency.ts +33 -0
  159. package/site/tooling/lib/deployment-config.ts +222 -0
  160. package/site/tooling/lib/deployment-manifest.ts +274 -0
  161. package/site/tooling/lib/deployment-prepare.ts +325 -0
  162. package/site/tooling/lib/deployment-upload.ts +293 -0
  163. package/site/tooling/lib/dev-git-state.ts +130 -0
  164. package/site/tooling/lib/dev-history.ts +86 -0
  165. package/site/tooling/lib/dev-social-images.ts +38 -0
  166. package/site/tooling/lib/dev-state.ts +107 -0
  167. package/site/tooling/lib/edition-builds.ts +184 -0
  168. package/site/tooling/lib/edition-paths.js +29 -0
  169. package/site/tooling/lib/empty-tag-route.js +23 -0
  170. package/site/tooling/lib/file-history.ts +201 -0
  171. package/site/tooling/lib/file-icons.ts +93 -0
  172. package/site/tooling/lib/file-policy.ts +38 -0
  173. package/site/tooling/lib/file-search.js +13 -0
  174. package/site/tooling/lib/git-revisions.ts +41 -0
  175. package/site/tooling/lib/history-pool.ts +74 -0
  176. package/site/tooling/lib/history-worker.ts +22 -0
  177. package/site/tooling/lib/knit-rmd.R +38 -0
  178. package/site/tooling/lib/lastmod.ts +201 -0
  179. package/site/tooling/lib/markdown-files.ts +51 -0
  180. package/site/tooling/lib/ofm.ts +634 -0
  181. package/site/tooling/lib/output.ts +60 -0
  182. package/site/tooling/lib/product-cache.ts +1006 -0
  183. package/site/tooling/lib/project-config.ts +288 -0
  184. package/site/tooling/lib/public-assets.ts +38 -0
  185. package/site/tooling/lib/public-links.ts +56 -0
  186. package/site/tooling/lib/publishing.ts +156 -0
  187. package/site/tooling/lib/r-runtime.ts +257 -0
  188. package/site/tooling/lib/rebuild-queue.ts +25 -0
  189. package/site/tooling/lib/rmd-preflight.ts +166 -0
  190. package/site/tooling/lib/rmd-preview-cache.ts +93 -0
  191. package/site/tooling/lib/rmd-previews.ts +129 -0
  192. package/site/tooling/lib/runtime-data-modules.ts +30 -0
  193. package/site/tooling/lib/runtime-packaging.ts +321 -0
  194. package/site/tooling/lib/search-cache.js +165 -0
  195. package/site/tooling/lib/search-inputs.js +129 -0
  196. package/site/tooling/lib/slug.ts +377 -0
  197. package/site/tooling/lib/social-images.ts +210 -0
  198. package/site/tooling/lib/text-exports.ts +51 -0
  199. package/site/tooling/lib/visit-elements.ts +17 -0
  200. package/site/tooling/live-test.ts +138 -0
  201. package/site/tooling/pipeline.ts +103 -0
  202. package/site/tooling/prepare.ts +3 -0
  203. package/site/tooling/publish.ts +28 -0
  204. package/site/tooling/r-cache.ts +45 -0
  205. package/site/tooling/r-render-profile.json +7 -0
  206. package/site/tooling/search.js +94 -0
  207. package/site/tooling/verify-deployment.ts +109 -0
  208. package/site/tooling/vite-content.ts +240 -0
  209. package/site/tooling/wrangler-paths.js +25 -0
  210. package/site/tooling/wrangler.ts +19 -0
  211. package/site/tsconfig.json +16 -0
  212. package/site/vite.config.ts +40 -0
  213. package/site/worker/access.ts +57 -0
  214. package/site/worker/analytics.ts +100 -0
  215. package/site/worker/auth-routes.ts +78 -0
  216. package/site/worker/auth.ts +78 -0
  217. package/site/worker/content-runtime.ts +226 -0
  218. package/site/worker/framing.ts +4 -0
  219. package/site/worker/gate.ts +147 -0
  220. package/site/worker/index.ts +9 -0
  221. package/site/worker/login.ts +62 -0
  222. package/site/worker/publication.ts +44 -0
  223. package/site/worker/runtime.ts +104 -0
  224. package/site/worker/tsconfig.json +13 -0
  225. 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
+ }