@pathmx/cloudflare 0.5.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/LICENSE.md ADDED
@@ -0,0 +1,172 @@
1
+ # PathMX Proprietary Beta License
2
+
3
+ Version 0.1 — July 21, 2026
4
+
5
+ Copyright © 2026 DUO Interactive, LLC.
6
+ All rights reserved.
7
+
8
+ This license governs the first-party PathMX software distributed in the npm
9
+ package `@pathmx/core`, including its command-line interface, bundled
10
+ library surfaces, runtime and Player components, and accompanying
11
+ documentation (the “Software”). DUO Interactive, LLC is the “Licensor.” This
12
+ license does not cover the private PathMX source-code repository or separately
13
+ identified third-party materials.
14
+
15
+ “Project” means Paths, Sources, responses, data, and other content authored or
16
+ processed with the Software.
17
+
18
+ “Application” means software or a service created by you in which the Software
19
+ supports product-specific functionality but is not itself offered as a
20
+ general-purpose PathMX product.
21
+
22
+ “Redistributable Components” means runtime, Player, or library portions of the
23
+ Software incorporated into a Project, Application, or generated output through
24
+ ordinary use of the Software or ordinary compilation and bundling tools.
25
+
26
+ “Authorized Users” means your employees and individual contractors who use the
27
+ Software on your behalf, and your hosting, continuous-integration, and other
28
+ service providers that possess or run necessary copies solely to provide
29
+ services to you.
30
+
31
+ ## 1. Agreement
32
+
33
+ By installing, accessing, or using the Software, you agree to this license. If
34
+ you use the Software on behalf of an organization, you represent that you have
35
+ authority to accept this license for that organization. If you do not agree,
36
+ do not install or use the Software.
37
+
38
+ ## 2. License grant
39
+
40
+ Subject to this license, the Licensor grants you a non-exclusive, worldwide,
41
+ royalty-free, non-transferable, and non-sublicensable license, perpetual for
42
+ the version of the Software to which it applies unless terminated under
43
+ Section 10, to:
44
+
45
+ - install, execute, and use the Software for personal, educational,
46
+ internal-business, or commercial purposes;
47
+ - use the Software to author, integrate, build, test, serve, play, host, and
48
+ operate Projects and Applications;
49
+ - import, compile, bundle, configure, and integrate the Software through its
50
+ documented library and command-line interfaces;
51
+ - create and distribute independent Projects, Applications, plugins, and
52
+ integrations that use those interfaces without modifying the Software
53
+ itself; and
54
+ - make copies reasonably necessary for local development, testing, continuous
55
+ integration, backup, and hosted production environments.
56
+
57
+ Your Authorized Users may exercise those rights solely on your behalf. You are
58
+ responsible for their compliance with this license.
59
+
60
+ You may reproduce and distribute Redistributable Components only as
61
+ incorporated into your Project, Application, or generated output, and only when
62
+ you preserve applicable license and proprietary notices. You may host and
63
+ commercially provide access to that Project, Application, or output. You may
64
+ not distribute Redistributable Components as a standalone package or as a
65
+ general-purpose substitute for the Software.
66
+
67
+ Each lawful recipient of a Redistributable Component may execute and use that
68
+ component, display its output, and make technically necessary transitory copies
69
+ solely as incorporated into the Project, Application, or output in which they
70
+ received it. This limited recipient permission does not include a right to
71
+ extract, modify, or redistribute the component separately.
72
+
73
+ ## 3. Restrictions
74
+
75
+ Except where applicable law does not allow the restriction, you may not:
76
+
77
+ - modify, adapt, translate, or create derivative works of the Software, except
78
+ for the compilation, bundling, configuration, and integration expressly
79
+ permitted in Section 2;
80
+ - redistribute, sublicense, sell, lease, or otherwise provide the Software to
81
+ another person as a standalone package, product, or service except to an
82
+ Authorized User or as a Redistributable Component under Section 2;
83
+ - resell, rebrand, or provide the Software as a hosted or managed service that
84
+ gives third parties general-purpose access to substantial PathMX authoring,
85
+ building, serving, Player-administration, or library functionality for their
86
+ own unrelated projects;
87
+ - reverse engineer, decompile, disassemble, or otherwise attempt to derive the
88
+ source code of the Software; or
89
+ - remove or alter copyright, license, trademark, or other proprietary notices.
90
+
91
+ For clarity, the managed-service restriction does not prohibit a hosted
92
+ Project or Application in which PathMX supports the licensee's own
93
+ product-specific experience and is not itself the general-purpose product
94
+ offered to customers.
95
+
96
+ ## 4. Your content
97
+
98
+ As between you and the Licensor, you retain ownership of the Paths, Sources,
99
+ responses, data, independent application code, and other content that you
100
+ author or provide while using the Software. You are responsible for that
101
+ content and for having the rights needed to use it.
102
+
103
+ Generated output may contain Redistributable Components or third-party
104
+ materials. Your ownership of your content does not change the ownership or
105
+ license terms of those components and materials.
106
+
107
+ ## 5. Ownership and trademarks
108
+
109
+ The Software is licensed, not sold. The Licensor and its suppliers retain all
110
+ rights in the Software not expressly granted by this license. This license
111
+ does not grant permission to use the PathMX name, logos, or other marks except
112
+ as reasonably necessary to identify the Software accurately.
113
+
114
+ ## 6. Beta status and changes
115
+
116
+ The Software is a proprietary beta release. Its APIs, behavior, file formats,
117
+ and features may change or be removed without notice. The Licensor has no
118
+ obligation to provide support, maintenance, updates, continued availability,
119
+ or backward compatibility. A later version may be offered under different
120
+ license terms, including an open-source license. Different terms for a later
121
+ version do not change the license previously granted for an earlier version.
122
+
123
+ ## 7. Third-party materials
124
+
125
+ The Software and Redistributable Components may include or depend on
126
+ third-party materials governed by their own license terms. Those terms control
127
+ for the applicable third-party materials. You must preserve third-party notices
128
+ when the applicable third-party terms require it.
129
+
130
+ ## 8. Disclaimer of warranties
131
+
132
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED “AS IS” AND
133
+ “AS AVAILABLE,” WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EXPRESS,
134
+ IMPLIED, OR STATUTORY, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
135
+ PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, OR THAT THE SOFTWARE WILL BE
136
+ SECURE, ERROR-FREE, OR UNINTERRUPTED.
137
+
138
+ ## 9. Limitation of liability
139
+
140
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE LICENSOR AND ITS CONTRIBUTORS,
141
+ SUPPLIERS, AND AFFILIATES WILL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL,
142
+ SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOSS OF DATA,
143
+ PROFITS, REVENUE, GOODWILL, OR BUSINESS OPPORTUNITY, ARISING FROM OR RELATED TO
144
+ THE SOFTWARE OR THIS LICENSE, EVEN IF ADVISED OF THE POSSIBILITY OF THOSE
145
+ DAMAGES.
146
+
147
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, THEIR TOTAL LIABILITY FOR ALL CLAIMS
148
+ ARISING FROM OR RELATED TO THE SOFTWARE OR THIS LICENSE WILL NOT EXCEED THE
149
+ GREATER OF THE AMOUNT YOU PAID FOR THE SOFTWARE DURING THE TWELVE MONTHS BEFORE
150
+ THE EVENT GIVING RISE TO THE CLAIM OR US$100.
151
+
152
+ ## 10. Termination
153
+
154
+ This license terminates if you materially breach it and do not cure the breach
155
+ within thirty days after receiving written notice from the Licensor. On
156
+ termination, you and your Authorized Users must stop using and delete your
157
+ copies of the Software. Sections 4, 5, 8, 9, 10, and 11 survive termination.
158
+ Termination does not withdraw the limited permission already granted by
159
+ Section 2 to lawful recipients of Redistributable Components distributed
160
+ before termination.
161
+
162
+ ## 11. General
163
+
164
+ If a provision of this license is unenforceable, it will be modified only to
165
+ the minimum extent necessary to make it enforceable, and the remaining
166
+ provisions will continue in effect. Failure to enforce a provision is not a
167
+ waiver. You may not assign or transfer this license without the Licensor's
168
+ written consent. The Licensor may assign this license together with ownership
169
+ of the Software to a successor owner. This license binds and benefits the
170
+ permitted successors and assigns of you and the Licensor. This license is the
171
+ entire agreement concerning the Software unless you and the Licensor have
172
+ signed a separate agreement that expressly replaces it.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @pathmx/cloudflare
2
+
3
+ Opt-in publication plugin for deploying PathMX static publications as a
4
+ Cloudflare Worker with Static Assets.
5
+
6
+ ```ts
7
+ // plugins/cloudflare.plugin.ts
8
+ import { CloudflarePlugin } from "@pathmx/cloudflare"
9
+
10
+ export default CloudflarePlugin({
11
+ accountId: "0123456789abcdef0123456789abcdef",
12
+ worker: "my-pathmx-site",
13
+ origin: "https://docs.example.com",
14
+ basePath: "/course-guide",
15
+ domain: "docs.example.com",
16
+ routing: { type: "route", zone: "example.com" },
17
+ })
18
+ ```
19
+
20
+ `basePath` mounts every canonical PathMX route—including exact Markdown,
21
+ runtime assets, polling records, and discovery documents—under one prefix. It
22
+ is useful when a PathMX publication lives beside another application on the
23
+ same origin.
24
+
25
+ Set `accountId` when local credentials can access more than one Cloudflare
26
+ account. It is written to the generated Wrangler configuration so validation
27
+ and deployment select the same account.
28
+
29
+ Domains default to Cloudflare Custom Domains. Select zone-route routing when
30
+ an existing Worker route must run before the domain; PathMX emits the exact
31
+ `docs.example.com/*` route so it outranks broader wildcard routes in that zone.
32
+
33
+ For a sibling source checkout, keep both packages local and override peer
34
+ resolution until they are published:
35
+
36
+ ```json
37
+ {
38
+ "dependencies": {
39
+ "@pathmx/cloudflare": "file:../minipathmx/pathmx/cloudflare",
40
+ "pathmx": "file:../minipathmx/pathmx/core"
41
+ },
42
+ "overrides": {
43
+ "pathmx": "file:../minipathmx/pathmx/core"
44
+ }
45
+ }
46
+ ```
47
+
48
+ Stage locally by default. Validation invokes pinned Wrangler without deploying;
49
+ deployment is the only command that mutates Cloudflare state.
50
+
51
+ ```sh
52
+ pmx cloudflare publish
53
+ pmx cloudflare publish --dry-run
54
+ pmx cloudflare publish --deploy
55
+ ```
56
+
57
+ The managed stage is written to `.pathmx/publish/cloudflare/`. Its generated
58
+ Worker applies the canonical publication route manifest, including Markdown
59
+ content negotiation and exact `.md` routes, before fetching immutable bodies
60
+ from the Static Assets binding.
package/commands.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { Command } from "commander"
2
+ import type { PluginCommandContext } from "@pathmx/core"
3
+ import type { CloudflarePluginOptions } from "./index.ts"
4
+ import {
5
+ runCloudflarePublish,
6
+ type CloudflarePublishAction,
7
+ } from "./publish.ts"
8
+
9
+ type PublishFlags = Readonly<{
10
+ dryRun?: boolean
11
+ deploy?: boolean
12
+ }>
13
+
14
+ function actionFrom(flags: PublishFlags): CloudflarePublishAction {
15
+ if (flags.dryRun && flags.deploy) {
16
+ throw new Error(
17
+ "Cloudflare publish accepts only one of --dry-run or --deploy.",
18
+ )
19
+ }
20
+ return flags.deploy ? "deploy" : flags.dryRun ? "dry-run" : "stage"
21
+ }
22
+
23
+ function completion(action: CloudflarePublishAction) {
24
+ if (action === "deploy") return "Deployed"
25
+ if (action === "dry-run") return "Validated"
26
+ return "Staged"
27
+ }
28
+
29
+ export function registerCloudflareCommands(
30
+ group: Command,
31
+ ctx: PluginCommandContext,
32
+ options: CloudflarePluginOptions,
33
+ ) {
34
+ group
35
+ .command("publish")
36
+ .description("Stage or deploy a Cloudflare static Worker publication")
37
+ .option("--dry-run", "validate the staged deployment with Wrangler")
38
+ .option("--deploy", "authenticate and deploy with Wrangler")
39
+ .action(async (flags: PublishFlags) => {
40
+ const action = actionFrom(flags)
41
+ const result = await runCloudflarePublish(ctx, options, action)
42
+ for (const warning of result.warnings) console.warn(`warning: ${warning}`)
43
+ console.log(
44
+ `${completion(action)} ${result.routeCount} routes for Cloudflare at ${result.stageDir}`,
45
+ )
46
+ })
47
+ }
package/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { definePlugin, type Plugin } from "@pathmx/core"
2
+ import { registerCloudflareCommands } from "./commands.ts"
3
+
4
+ export type CloudflareRouting =
5
+ | Readonly<{ type: "custom-domain" }>
6
+ | Readonly<{ type: "route"; zone: string }>
7
+
8
+ export type CloudflarePluginOptions = Readonly<{
9
+ /** Optional Cloudflare account id used when more than one account is available. */
10
+ accountId?: string
11
+ /** Cloudflare Worker name. */
12
+ worker: string
13
+ /** Canonical HTTPS publication origin. */
14
+ origin: string
15
+ /** Optional same-origin mount path, for example `/course-guide`. */
16
+ basePath?: string
17
+ /** Optional custom domain attached only during deployment. */
18
+ domain?: string
19
+ /** How the domain is attached. Defaults to a Custom Domain. */
20
+ routing?: CloudflareRouting
21
+ }>
22
+
23
+ /** Add an explicit Cloudflare static-Worker publication command. */
24
+ export function CloudflarePlugin(options: CloudflarePluginOptions): Plugin {
25
+ return definePlugin({
26
+ id: "cloudflare",
27
+ name: "Cloudflare",
28
+ commands(group, ctx) {
29
+ registerCloudflareCommands(group, ctx, options)
30
+ },
31
+ })
32
+ }
33
+
34
+ export default CloudflarePlugin
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@pathmx/cloudflare",
3
+ "description": "Cloudflare publication support for PathMX.",
4
+ "version": "0.5.0",
5
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pathmx/pathmx-beta.git",
9
+ "directory": "pathmx/cloudflare"
10
+ },
11
+ "homepage": "https://github.com/pathmx/pathmx-beta#readme",
12
+ "bugs": "https://github.com/pathmx/pathmx-beta/issues",
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "registry": "https://registry.npmjs.org/",
16
+ "tag": "beta"
17
+ },
18
+ "type": "module",
19
+ "exports": "./index.ts",
20
+ "engines": {
21
+ "bun": ">=1.4.0"
22
+ },
23
+ "peerDependencies": {
24
+ "@pathmx/core": "^0.5.0"
25
+ },
26
+ "dependencies": {
27
+ "commander": "^15.0.0"
28
+ }
29
+ }
package/publish.ts ADDED
@@ -0,0 +1,512 @@
1
+ import {
2
+ lstat,
3
+ mkdir,
4
+ mkdtemp,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ writeFile,
9
+ } from "node:fs/promises"
10
+ import {
11
+ basename,
12
+ dirname,
13
+ isAbsolute,
14
+ join,
15
+ relative,
16
+ resolve,
17
+ sep,
18
+ } from "node:path"
19
+ import type { PluginCommandContext } from "@pathmx/core"
20
+ import { Log, PathMX } from "@pathmx/core/bun"
21
+ import {
22
+ createPublication,
23
+ type PublicationManifest,
24
+ type PublicationRepresentation,
25
+ type PublicationStore,
26
+ } from "@pathmx/core/publish"
27
+ import type { CloudflarePluginOptions } from "./index.ts"
28
+ import type {
29
+ CloudflareRepresentation,
30
+ CloudflareRoute,
31
+ CloudflareRoutes,
32
+ } from "./runtime.ts"
33
+
34
+ export const PATHMX_WRANGLER_VERSION = "4.125.0"
35
+ export const CLOUDFLARE_COMPATIBILITY_DATE = "2026-08-24"
36
+
37
+ const RECEIPT = "receipt.json"
38
+ const RECEIPT_TYPE = "pathmx-cloudflare-publication"
39
+ const RECEIPT_VERSION = 1
40
+ const FREE_ASSET_LIMIT = 20_000
41
+ const MAX_ASSET_LIMIT = 100_000
42
+ const MAX_ASSET_BYTES = 25 * 1024 * 1024
43
+ const ACCOUNT_ID = /^[a-f0-9]{32}$/
44
+ const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
45
+ const DOMAIN =
46
+ /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
47
+
48
+ export type CloudflarePublishAction = "stage" | "dry-run" | "deploy"
49
+
50
+ export type CloudflareCommandRequest = Readonly<{
51
+ cwd: string
52
+ command: readonly string[]
53
+ }>
54
+
55
+ export type CloudflareCommandRunner = (
56
+ request: CloudflareCommandRequest,
57
+ ) => Promise<void>
58
+
59
+ type PublishContext = Pick<
60
+ PluginCommandContext,
61
+ "pathsRoot" | "actionsRoot" | "projectRoot" | "stateRoot"
62
+ >
63
+
64
+ type ResolvedOptions = Readonly<{
65
+ accountId?: string
66
+ worker: string
67
+ origin: string
68
+ basePath?: string
69
+ domain?: string
70
+ routing?: CloudflarePluginOptions["routing"]
71
+ }>
72
+
73
+ type AssetInventory = Readonly<{
74
+ count: number
75
+ largest: Readonly<{ path: string; size: number }>
76
+ }>
77
+
78
+ function errorCode(error: unknown) {
79
+ return typeof error === "object" && error !== null && "code" in error
80
+ ? String(error.code)
81
+ : undefined
82
+ }
83
+
84
+ function resolveOptions(options: CloudflarePluginOptions): ResolvedOptions {
85
+ const accountId = options.accountId?.trim().toLowerCase()
86
+ if (accountId && !ACCOUNT_ID.test(accountId)) {
87
+ throw new Error(
88
+ "Cloudflare account id must be a 32-character hexadecimal value.",
89
+ )
90
+ }
91
+ if (!WORKER_NAME.test(options.worker)) {
92
+ throw new Error(
93
+ "Cloudflare worker must be 1-63 lowercase letters, numbers, or hyphens.",
94
+ )
95
+ }
96
+
97
+ let origin: URL
98
+ try {
99
+ origin = new URL(options.origin)
100
+ } catch {
101
+ throw new Error(`Invalid Cloudflare publication origin: ${options.origin}`)
102
+ }
103
+ if (
104
+ origin.protocol !== "https:" ||
105
+ origin.username ||
106
+ origin.password ||
107
+ origin.pathname !== "/" ||
108
+ origin.search ||
109
+ origin.hash
110
+ ) {
111
+ throw new Error(
112
+ "Cloudflare publication origin must be an HTTPS origin without credentials, path, query, or fragment.",
113
+ )
114
+ }
115
+
116
+ const domain = options.domain?.trim().toLowerCase()
117
+ if (domain && !DOMAIN.test(domain)) {
118
+ throw new Error(`Invalid Cloudflare custom domain: ${options.domain}`)
119
+ }
120
+ if (domain && origin.hostname !== domain) {
121
+ throw new Error(
122
+ `Cloudflare custom domain ${domain} must match publication origin ${origin.origin}.`,
123
+ )
124
+ }
125
+ if (options.routing && !domain) {
126
+ throw new Error("Cloudflare routing requires a domain.")
127
+ }
128
+ let routing = options.routing
129
+ if (routing?.type === "route") {
130
+ const zone = routing.zone.trim().toLowerCase()
131
+ if (
132
+ !DOMAIN.test(zone) ||
133
+ (domain !== zone && !domain?.endsWith(`.${zone}`))
134
+ ) {
135
+ throw new Error(
136
+ `Cloudflare route zone ${routing.zone} must contain domain ${domain}.`,
137
+ )
138
+ }
139
+ routing = Object.freeze({ type: "route", zone })
140
+ }
141
+ return Object.freeze({
142
+ ...(accountId ? { accountId } : {}),
143
+ worker: options.worker,
144
+ origin: origin.origin,
145
+ ...(options.basePath ? { basePath: options.basePath } : {}),
146
+ ...(domain ? { domain } : {}),
147
+ ...(routing ? { routing } : {}),
148
+ })
149
+ }
150
+
151
+ function representation(
152
+ value: PublicationRepresentation,
153
+ ): CloudflareRepresentation {
154
+ return Object.freeze({
155
+ artifact: value.artifact,
156
+ contentType: value.contentType,
157
+ ...(value.headers ? { headers: value.headers } : {}),
158
+ })
159
+ }
160
+
161
+ export function createCloudflareRoutes(
162
+ manifest: PublicationManifest,
163
+ ): CloudflareRoutes {
164
+ const routes: Record<string, CloudflareRoute> = {}
165
+ for (const route of manifest.routes) {
166
+ const defaults = route.representations.filter((item) => item.default)
167
+ if (defaults.length !== 1) {
168
+ throw new Error(
169
+ `Cloudflare route ${route.pathname} requires exactly one default representation.`,
170
+ )
171
+ }
172
+ const alternatives = route.representations.filter((item) => !item.default)
173
+ const markdown = alternatives.find(
174
+ (item) => item.type === "markdown" && item.accept === "text/markdown",
175
+ )
176
+ if (alternatives.some((item) => item !== markdown)) {
177
+ throw new Error(
178
+ `Cloudflare route ${route.pathname} has an unsupported representation.`,
179
+ )
180
+ }
181
+ routes[route.pathname] = Object.freeze({
182
+ default: representation(defaults[0]!),
183
+ ...(markdown
184
+ ? {
185
+ markdown: Object.freeze({
186
+ ...representation(markdown),
187
+ ...(markdown.alternatePath
188
+ ? { alternatePath: markdown.alternatePath }
189
+ : {}),
190
+ }),
191
+ }
192
+ : {}),
193
+ })
194
+ }
195
+ return Object.freeze(routes)
196
+ }
197
+
198
+ function renderWrangler(options: ResolvedOptions) {
199
+ return `${JSON.stringify(
200
+ {
201
+ $schema:
202
+ "https://raw.githubusercontent.com/cloudflare/workers-sdk/main/packages/wrangler/config-schema.json",
203
+ ...(options.accountId ? { account_id: options.accountId } : {}),
204
+ name: options.worker,
205
+ main: "./worker.ts",
206
+ compatibility_date: CLOUDFLARE_COMPATIBILITY_DATE,
207
+ workers_dev: true,
208
+ preview_urls: true,
209
+ assets: {
210
+ directory: "./site",
211
+ binding: "ASSETS",
212
+ run_worker_first: true,
213
+ html_handling: "none",
214
+ not_found_handling: "none",
215
+ },
216
+ ...(options.domain
217
+ ? {
218
+ routes:
219
+ options.routing?.type === "route"
220
+ ? [
221
+ {
222
+ pattern: `${options.domain}/*`,
223
+ zone_name: options.routing.zone,
224
+ },
225
+ ]
226
+ : [{ pattern: options.domain, custom_domain: true }],
227
+ }
228
+ : {}),
229
+ },
230
+ null,
231
+ 2,
232
+ )}\n`
233
+ }
234
+
235
+ function inspectAssets(manifest: PublicationManifest): AssetInventory {
236
+ const count = manifest.artifacts.length
237
+ if (count > MAX_ASSET_LIMIT) {
238
+ throw new Error(
239
+ `Cloudflare publication has ${count} assets; the platform maximum is ${MAX_ASSET_LIMIT}.`,
240
+ )
241
+ }
242
+
243
+ const largest = manifest.artifacts.reduce(
244
+ (current, artifact) =>
245
+ artifact.size > current.size
246
+ ? { path: artifact.path, size: artifact.size }
247
+ : current,
248
+ { path: "", size: 0 },
249
+ )
250
+ if (largest.size > MAX_ASSET_BYTES) {
251
+ throw new Error(
252
+ `Cloudflare asset exceeds 25 MiB: ${largest.path} (${largest.size} bytes).`,
253
+ )
254
+ }
255
+ return Object.freeze({ count, largest: Object.freeze(largest) })
256
+ }
257
+
258
+ function artifactPath(root: string, artifact: string) {
259
+ const path = resolve(root, artifact)
260
+ const local = relative(root, path)
261
+ if (
262
+ !artifact ||
263
+ isAbsolute(artifact) ||
264
+ local === ".." ||
265
+ local.startsWith(`..${sep}`) ||
266
+ isAbsolute(local)
267
+ ) {
268
+ throw new Error(`Invalid Cloudflare publication artifact: ${artifact}`)
269
+ }
270
+ return path
271
+ }
272
+
273
+ function cloudflareStore(root: string): PublicationStore {
274
+ return Object.freeze({
275
+ async put(artifact: string, body: Blob) {
276
+ const path = artifactPath(root, artifact)
277
+ await mkdir(dirname(path), { recursive: true })
278
+ await Bun.write(path, body)
279
+ return Object.freeze({
280
+ size: body.size,
281
+ read: async () => Bun.file(path),
282
+ })
283
+ },
284
+ })
285
+ }
286
+
287
+ async function assertManagedOrMissing(stageDir: string) {
288
+ try {
289
+ const info = await lstat(stageDir)
290
+ if (!info.isDirectory() || info.isSymbolicLink()) {
291
+ throw new Error(
292
+ `Cloudflare publish stage is not a regular directory: ${stageDir}`,
293
+ )
294
+ }
295
+ } catch (error) {
296
+ if (errorCode(error) === "ENOENT") return
297
+ throw error
298
+ }
299
+
300
+ const receiptPath = join(stageDir, RECEIPT)
301
+ let receiptInfo
302
+ try {
303
+ receiptInfo = await lstat(receiptPath)
304
+ } catch (error) {
305
+ if (errorCode(error) === "ENOENT") {
306
+ throw new Error(
307
+ `Refusing to replace unowned Cloudflare publish stage: ${stageDir}`,
308
+ )
309
+ }
310
+ throw error
311
+ }
312
+ if (!receiptInfo.isFile() || receiptInfo.isSymbolicLink()) {
313
+ throw new Error(`Invalid Cloudflare publish receipt: ${receiptPath}`)
314
+ }
315
+
316
+ let receipt: unknown
317
+ try {
318
+ receipt = JSON.parse(await readFile(receiptPath, "utf8"))
319
+ } catch {
320
+ throw new Error(`Invalid Cloudflare publish receipt: ${receiptPath}`)
321
+ }
322
+ if (
323
+ !receipt ||
324
+ typeof receipt !== "object" ||
325
+ (receipt as { type?: unknown }).type !== RECEIPT_TYPE ||
326
+ (receipt as { version?: unknown }).version !== RECEIPT_VERSION
327
+ ) {
328
+ throw new Error(`Invalid Cloudflare publish receipt: ${receiptPath}`)
329
+ }
330
+ }
331
+
332
+ async function replaceManagedStage(candidate: string, stageDir: string) {
333
+ await assertManagedOrMissing(stageDir)
334
+ const backup = `${candidate}.previous`
335
+ let moved = false
336
+ try {
337
+ await rename(stageDir, backup)
338
+ moved = true
339
+ } catch (error) {
340
+ if (errorCode(error) !== "ENOENT") throw error
341
+ }
342
+ try {
343
+ await rename(candidate, stageDir)
344
+ } catch (error) {
345
+ if (moved) {
346
+ await rename(backup, stageDir)
347
+ }
348
+ throw error
349
+ }
350
+ if (moved) {
351
+ await rm(backup, { recursive: true, force: true }).catch(() => undefined)
352
+ }
353
+ }
354
+
355
+ async function writeAdapterFiles(
356
+ candidate: string,
357
+ manifest: PublicationManifest,
358
+ options: ResolvedOptions,
359
+ inventory: AssetInventory,
360
+ ) {
361
+ const routes = createCloudflareRoutes(manifest)
362
+ const runtime = await readFile(
363
+ new URL("./runtime.ts", import.meta.url),
364
+ "utf8",
365
+ )
366
+ await Promise.all([
367
+ writeFile(join(candidate, "routes.json"), `${JSON.stringify(routes)}\n`),
368
+ writeFile(join(candidate, "worker-runtime.ts"), runtime),
369
+ writeFile(
370
+ join(candidate, "worker.ts"),
371
+ [
372
+ 'import { createCloudflareWorker } from "./worker-runtime.ts"',
373
+ 'import routes from "./routes.json" with { type: "json" }',
374
+ "",
375
+ "export default createCloudflareWorker(routes)",
376
+ "",
377
+ ].join("\n"),
378
+ ),
379
+ writeFile(join(candidate, "wrangler.jsonc"), renderWrangler(options)),
380
+ writeFile(
381
+ join(candidate, RECEIPT),
382
+ `${JSON.stringify(
383
+ {
384
+ type: RECEIPT_TYPE,
385
+ version: RECEIPT_VERSION,
386
+ ...(options.accountId ? { accountId: options.accountId } : {}),
387
+ worker: options.worker,
388
+ origin: options.origin,
389
+ basePath: manifest.basePath,
390
+ ...(options.routing ? { routing: options.routing } : {}),
391
+ build: manifest.build,
392
+ routes: manifest.routes.length,
393
+ assets: inventory.count,
394
+ wrangler: PATHMX_WRANGLER_VERSION,
395
+ compatibilityDate: CLOUDFLARE_COMPATIBILITY_DATE,
396
+ },
397
+ null,
398
+ 2,
399
+ )}\n`,
400
+ ),
401
+ ])
402
+ }
403
+
404
+ export const defaultCloudflareCommandRunner: CloudflareCommandRunner = async ({
405
+ cwd,
406
+ command,
407
+ }) => {
408
+ const child = Bun.spawn([...command], {
409
+ cwd,
410
+ stdin: "inherit",
411
+ stdout: "inherit",
412
+ stderr: "inherit",
413
+ })
414
+ const exitCode = await child.exited
415
+ if (exitCode !== 0) {
416
+ throw new Error(
417
+ `Cloudflare command exited with status ${exitCode}: ${command.join(" ")}`,
418
+ )
419
+ }
420
+ }
421
+
422
+ function wranglerCommand(stageDir: string, ...args: string[]) {
423
+ return [
424
+ "bunx",
425
+ `wrangler@${PATHMX_WRANGLER_VERSION}`,
426
+ ...args,
427
+ "--config",
428
+ join(stageDir, "wrangler.jsonc"),
429
+ ]
430
+ }
431
+
432
+ async function runProviderAction(
433
+ action: CloudflarePublishAction,
434
+ context: PublishContext,
435
+ stageDir: string,
436
+ run: CloudflareCommandRunner,
437
+ ) {
438
+ if (action === "stage") return
439
+ if (action === "dry-run") {
440
+ await run({
441
+ cwd: context.projectRoot,
442
+ command: wranglerCommand(stageDir, "deploy", "--dry-run"),
443
+ })
444
+ return
445
+ }
446
+ await run({
447
+ cwd: context.projectRoot,
448
+ command: ["bunx", `wrangler@${PATHMX_WRANGLER_VERSION}`, "whoami"],
449
+ })
450
+ await run({
451
+ cwd: context.projectRoot,
452
+ command: wranglerCommand(stageDir, "deploy"),
453
+ })
454
+ }
455
+
456
+ export async function runCloudflarePublish(
457
+ context: PublishContext,
458
+ configured: CloudflarePluginOptions,
459
+ action: CloudflarePublishAction,
460
+ run: CloudflareCommandRunner = defaultCloudflareCommandRunner,
461
+ ) {
462
+ const options = resolveOptions(configured)
463
+ const stageDir = join(context.projectRoot, ".pathmx", "publish", "cloudflare")
464
+ await assertManagedOrMissing(stageDir)
465
+ const parent = dirname(stageDir)
466
+ await mkdir(parent, { recursive: true })
467
+ const candidate = await mkdtemp(join(parent, `.${basename(stageDir)}-`))
468
+ const siteDir = join(candidate, "site")
469
+ let app: Awaited<ReturnType<typeof PathMX>> | undefined
470
+
471
+ try {
472
+ app = await PathMX(context.pathsRoot, {
473
+ actionsRoot: context.actionsRoot,
474
+ stateRoot: context.stateRoot,
475
+ diagnostics: "throw",
476
+ runtime: { mode: "production", target: "static" },
477
+ log: new Log({ level: "error" }),
478
+ })
479
+ const exportingApp = app
480
+ await mkdir(siteDir)
481
+ const publication = await createPublication(exportingApp, {
482
+ origin: options.origin,
483
+ basePath: options.basePath,
484
+ store: cloudflareStore(siteDir),
485
+ })
486
+ await exportingApp.close()
487
+ app = undefined
488
+ const manifest = publication.manifest
489
+ const inventory = inspectAssets(manifest)
490
+ await writeAdapterFiles(candidate, manifest, options, inventory)
491
+ await replaceManagedStage(candidate, stageDir)
492
+ await runProviderAction(action, context, stageDir, run)
493
+ const warnings =
494
+ inventory.count > FREE_ASSET_LIMIT
495
+ ? [
496
+ `publication has ${inventory.count} static assets; Cloudflare's free plan permits ${FREE_ASSET_LIMIT}`,
497
+ ]
498
+ : []
499
+ return Object.freeze({
500
+ action,
501
+ stageDir,
502
+ siteDir: join(stageDir, "site"),
503
+ routeCount: manifest.routes.length,
504
+ assetCount: inventory.count,
505
+ largestAsset: inventory.largest,
506
+ warnings: Object.freeze(warnings),
507
+ })
508
+ } finally {
509
+ await app?.close().catch(() => undefined)
510
+ await rm(candidate, { recursive: true, force: true })
511
+ }
512
+ }
package/runtime.ts ADDED
@@ -0,0 +1,154 @@
1
+ export type CloudflareRepresentation = Readonly<{
2
+ artifact: string
3
+ contentType: string
4
+ headers?: Readonly<Record<string, string>>
5
+ }>
6
+
7
+ export type CloudflareRoute = Readonly<{
8
+ default: CloudflareRepresentation
9
+ markdown?: CloudflareRepresentation &
10
+ Readonly<{
11
+ alternatePath?: string
12
+ }>
13
+ }>
14
+
15
+ export type CloudflareRoutes = Readonly<Record<string, CloudflareRoute>>
16
+
17
+ type AssetsBinding = Readonly<{
18
+ fetch(request: Request): Promise<Response>
19
+ }>
20
+
21
+ type CloudflareEnvironment = Readonly<{
22
+ ASSETS: AssetsBinding
23
+ }>
24
+
25
+ function acceptedQuality(header: string, target: string) {
26
+ const [targetType, targetSubtype] = target.split("/")
27
+ let quality = 0
28
+ let specificity = -1
29
+
30
+ for (const entry of header.split(",")) {
31
+ const [range = "", ...parameters] = entry.split(";")
32
+ const [type, subtype] = range.trim().toLowerCase().split("/")
33
+ if (
34
+ !type ||
35
+ !subtype ||
36
+ (type !== "*" && type !== targetType) ||
37
+ (subtype !== "*" && subtype !== targetSubtype)
38
+ ) {
39
+ continue
40
+ }
41
+
42
+ const candidateSpecificity =
43
+ (type === "*" ? 0 : 1) + (subtype === "*" ? 0 : 1)
44
+ const q = parameters
45
+ .map((parameter) => parameter.trim().match(/^q\s*=\s*(.+)$/i)?.[1])
46
+ .find((value) => value !== undefined)
47
+ const parsed = q === undefined ? 1 : Number(q)
48
+ const candidateQuality =
49
+ Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0
50
+
51
+ if (
52
+ candidateSpecificity > specificity ||
53
+ (candidateSpecificity === specificity && candidateQuality > quality)
54
+ ) {
55
+ specificity = candidateSpecificity
56
+ quality = candidateQuality
57
+ }
58
+ }
59
+
60
+ return quality
61
+ }
62
+
63
+ function prefersMarkdown(request: Request) {
64
+ const accept = request.headers.get("Accept")
65
+ if (!accept) return false
66
+ const markdown = acceptedQuality(accept, "text/markdown")
67
+ return markdown > 0 && markdown > acceptedQuality(accept, "text/html")
68
+ }
69
+
70
+ function appendVary(headers: Headers, name: string) {
71
+ const values = (headers.get("Vary") ?? "")
72
+ .split(",")
73
+ .map((value) => value.trim())
74
+ .filter(Boolean)
75
+ if (!values.some((value) => value.toLowerCase() === name.toLowerCase())) {
76
+ values.push(name)
77
+ }
78
+ headers.set("Vary", values.join(", "))
79
+ }
80
+
81
+ function representationFor(route: CloudflareRoute, request: Request) {
82
+ return route.markdown && prefersMarkdown(request)
83
+ ? route.markdown
84
+ : route.default
85
+ }
86
+
87
+ function artifactRequest(request: Request, artifact: string) {
88
+ const url = new URL(request.url)
89
+ url.pathname = `/${artifact.replace(/^\/+/, "")}`
90
+ url.search = ""
91
+ return new Request(url, {
92
+ method: request.method,
93
+ headers: request.headers,
94
+ })
95
+ }
96
+
97
+ function routeResponse(
98
+ response: Response,
99
+ representation: CloudflareRepresentation,
100
+ route: CloudflareRoute,
101
+ ) {
102
+ if (response.status === 404) {
103
+ return new Response("Publication artifact unavailable", { status: 502 })
104
+ }
105
+ const output = new Response(response.body, response)
106
+ output.headers.set("Content-Type", representation.contentType)
107
+ for (const [name, value] of Object.entries(representation.headers ?? {})) {
108
+ output.headers.set(name, value)
109
+ }
110
+ if (route.markdown?.alternatePath && output.status < 400) {
111
+ appendVary(output.headers, "Accept")
112
+ const alternate = new URL(
113
+ route.markdown.alternatePath,
114
+ "https://pathmx.local",
115
+ ).pathname
116
+ output.headers.append(
117
+ "Link",
118
+ `<${alternate}>; rel="alternate"; type="text/markdown"`,
119
+ )
120
+ }
121
+ return output
122
+ }
123
+
124
+ /** Create the generated Worker's portable request handler. */
125
+ export function createCloudflareWorker(routes: CloudflareRoutes) {
126
+ return Object.freeze({
127
+ async fetch(request: Request, env: CloudflareEnvironment) {
128
+ if (request.method !== "GET" && request.method !== "HEAD") {
129
+ return new Response("Method Not Allowed", {
130
+ status: 405,
131
+ headers: { Allow: "GET, HEAD" },
132
+ })
133
+ }
134
+
135
+ const url = new URL(request.url)
136
+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
137
+ return new Response(null, {
138
+ status: 308,
139
+ headers: {
140
+ Location: url.pathname.replace(/\/+$/, "") + url.search,
141
+ },
142
+ })
143
+ }
144
+
145
+ const route = routes[url.pathname]
146
+ if (!route) return new Response("Not Found", { status: 404 })
147
+ const representation = representationFor(route, request)
148
+ const response = await env.ASSETS.fetch(
149
+ artifactRequest(request, representation.artifact),
150
+ )
151
+ return routeResponse(response, representation, route)
152
+ },
153
+ })
154
+ }