make-cloudflare 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: make-cloudflare
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler.
5
5
  Project-URL: Homepage, https://make.optersoft.com
6
6
  Author-email: "Optersoft, S.L." <david@optersoft.com>
@@ -29,6 +29,7 @@ from make_cloudflare import cloudflare # importing is what registers the group
29
29
  ```console
30
30
  $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
31
31
  $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
32
+ $ mk cloudflare.deploy dist --functions functions # with Pages Functions
32
33
  $ mk cloudflare.projects # what the account has
33
34
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
34
35
  $ mk cloudflare.prune --keep 10 # delete all but the newest ten
@@ -50,6 +51,17 @@ Python). Pages never deletes one by itself, and each stays reachable on its own
50
51
  never deleted, however old; a pruned preview that was still its branch's alias
51
52
  takes that branch URL with it.
52
53
 
54
+ **Pages Functions go up with the deployment.** `--functions functions` (or
55
+ `pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
56
+ directory, as wrangler expects, and `/api/...` answers from the same project and
57
+ origin. Bundling it is a build step, not an upload, so that one step is
58
+ `wrangler pages functions build --outfile` (from PATH, or through `npx`). It
59
+ writes exactly the `_worker.bundle` the deployment takes, plus
60
+ `functions-filepath-routing-config.json` and a generated `_routes.json`; the
61
+ site's own `_routes.json` wins. Everything else is still the four calls below.
62
+ Bindings (D1, KV) and secrets are settings on the Pages project, never part of
63
+ the upload.
64
+
53
65
  ## Why this exists
54
66
 
55
67
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -80,9 +92,9 @@ call returns 200 and the site serves the old bytes, or none:
80
92
 
81
93
  ## What it does not do
82
94
 
83
- **Pages Functions.** A directory holding `_worker.js` or `functions/` is
84
- refused by name rather than half-deployed: bundling a Worker is a build step,
85
- not an upload, and wrangler should keep doing it.
95
+ **Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
96
+ the built directory is refused by name: it would otherwise be served as a
97
+ static file. Pages Functions go beside it, through `--functions`.
86
98
 
87
99
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
88
100
  Cloudflare can change them without a changelog; the documented `deployments`
@@ -16,6 +16,7 @@ from make_cloudflare import cloudflare # importing is what registers the group
16
16
  ```console
17
17
  $ mk cloudflare.deploy site/dist --project mkrun # production (branch main)
18
18
  $ mk cloudflare.deploy site/dist --branch try # a preview, on its own URL
19
+ $ mk cloudflare.deploy dist --functions functions # with Pages Functions
19
20
  $ mk cloudflare.projects # what the account has
20
21
  $ mk cloudflare.deployments --limit 5 # one project's recent ones
21
22
  $ mk cloudflare.prune --keep 10 # delete all but the newest ten
@@ -37,6 +38,17 @@ Python). Pages never deletes one by itself, and each stays reachable on its own
37
38
  never deleted, however old; a pruned preview that was still its branch's alias
38
39
  takes that branch URL with it.
39
40
 
41
+ **Pages Functions go up with the deployment.** `--functions functions` (or
42
+ `pages.deploy(..., functions=)`) names the tree. It sits **beside** the built
43
+ directory, as wrangler expects, and `/api/...` answers from the same project and
44
+ origin. Bundling it is a build step, not an upload, so that one step is
45
+ `wrangler pages functions build --outfile` (from PATH, or through `npx`). It
46
+ writes exactly the `_worker.bundle` the deployment takes, plus
47
+ `functions-filepath-routing-config.json` and a generated `_routes.json`; the
48
+ site's own `_routes.json` wins. Everything else is still the four calls below.
49
+ Bindings (D1, KV) and secrets are settings on the Pages project, never part of
50
+ the upload.
51
+
40
52
  ## Why this exists
41
53
 
42
54
  `wrangler pages deploy` is four HTTPS calls and a hash. Reaching them through
@@ -67,9 +79,9 @@ call returns 200 and the site serves the old bytes, or none:
67
79
 
68
80
  ## What it does not do
69
81
 
70
- **Pages Functions.** A directory holding `_worker.js` or `functions/` is
71
- refused by name rather than half-deployed: bundling a Worker is a build step,
72
- not an upload, and wrangler should keep doing it.
82
+ **Advanced-mode `_worker.js`.** A `_worker.js` (or a `functions/`) *inside*
83
+ the built directory is refused by name: it would otherwise be served as a
84
+ static file. Pages Functions go beside it, through `--functions`.
73
85
 
74
86
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them, and
75
87
  Cloudflare can change them without a changelog; the documented `deployments`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "make-cloudflare"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Cloudflare Pages direct upload for mkrun -- deploy a built site with no Node and no wrangler."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -17,7 +17,7 @@ namespaces, and this package claims only the four names above.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- __version__ = "0.2.0"
20
+ __version__ = "0.3.0"
21
21
 
22
22
  __all__ = ["cloudflare", "pages"]
23
23
 
@@ -24,15 +24,19 @@ def deploy(
24
24
  project: str | None = None,
25
25
  branch: Annotated[str, arg(help="the Pages branch; the production one publishes live")] = "main",
26
26
  keep: Annotated[int, arg(help="deployments to keep afterwards; 0 keeps every one")] = pages.KEEP,
27
+ functions: Annotated[str | None, arg(help="a Pages Functions tree to bundle and deploy with it")] = None,
27
28
  ) -> None:
28
29
  """Publish a built directory to Cloudflare Pages by direct upload.
29
30
 
30
- No Node and no wrangler: four HTTPS calls. `--branch` is what decides
31
+ No Node and no wrangler: four HTTPS calls. (`--functions` is the exception:
32
+ wrangler bundles that tree, and only bundles it.) `--branch` is what decides
31
33
  production -- the project's production branch goes live, every other name
32
34
  lands as a preview on its own URL. Then the project is pruned to its
33
35
  newest `--keep` deployments; the live one always stays.
34
36
  """
35
- url = pages.deploy(Path(directory), project=pages.project_name(project), branch=branch, keep=keep)
37
+ url = pages.deploy(
38
+ Path(directory), project=pages.project_name(project), branch=branch, keep=keep, functions=functions
39
+ )
36
40
  note(f"deployed -- {url}")
37
41
 
38
42
 
@@ -8,6 +8,7 @@ a built directory without installing a JavaScript toolchain to do it.
8
8
 
9
9
  pages.deploy("site/dist", project="mkrun") # production
10
10
  pages.deploy("site/dist", project="mkrun", branch="x") # a preview
11
+ pages.deploy("dist", project="name", functions="functions") # with Pages Functions
11
12
 
12
13
  The flow, and the three things a naive port gets wrong:
13
14
 
@@ -30,6 +31,13 @@ reports success while the site's CSP quietly stops applying.
30
31
  **Steps 2 and 3 are undocumented.** They exist because wrangler uses them;
31
32
  Cloudflare can change them without a changelog. The documented `deployments`
32
33
  endpoint alone cannot upload a file.
34
+
35
+ **Pages Functions are built by wrangler, and only built.** Bundling a
36
+ `functions/` tree into a Worker is esbuild plus Cloudflare's routing glue, and
37
+ `wrangler pages functions build --outfile` writes exactly the `_worker.bundle`
38
+ the deployment takes. That one build step is the only thing wrangler does here;
39
+ the upload is still these calls. Bindings (D1, KV) and secrets are settings on
40
+ the Pages project, never part of the upload.
33
41
  """
34
42
 
35
43
  from __future__ import annotations
@@ -39,17 +47,20 @@ import json
39
47
  import mimetypes
40
48
  import os
41
49
  import secrets as _random
50
+ import shutil
51
+ import tempfile
42
52
  from collections.abc import Iterable, Iterator, Mapping
43
53
  from dataclasses import dataclass
44
54
  from pathlib import Path
45
55
  from typing import Any
46
56
 
47
- from make import env, http, note, step
57
+ from make import env, http, note, sh, step
48
58
  from make.context import mark_sensitive
49
59
  from make.errors import ConfigError, MakeError
50
60
 
51
61
  __all__ = [
52
62
  "Asset",
63
+ "build_functions",
53
64
  "collect",
54
65
  "deploy",
55
66
  "deployments",
@@ -84,9 +95,10 @@ PER_PAGE = 25
84
95
  #: Handled by Pages itself, as fields on the deployment -- never as assets.
85
96
  SPECIAL_FILES = ("_headers", "_redirects", "_routes.json")
86
97
 
87
- #: Never uploaded. `_worker.js` and `functions/` are Pages Functions, which this
88
- #: module does not build: a directory that has them is a Functions project and
89
- #: belongs on wrangler until someone needs it here.
98
+ #: Never uploaded. Pages Functions live *beside* the built directory and are
99
+ #: handed to `deploy(functions=...)`; inside it they would be served as static
100
+ #: files -- the source of the API, readable by anyone. An advanced-mode
101
+ #: `_worker.js` is not built here at all.
90
102
  SKIP_NAMES = {".DS_Store", "Thumbs.db", ".gitkeep", *SPECIAL_FILES}
91
103
  SKIP_DIRS = {".git", "node_modules", "__pycache__", ".wrangler"}
92
104
  FUNCTIONS = ("_worker.js", "_worker.js.map", "_worker.bundle", "functions")
@@ -146,8 +158,9 @@ def collect(directory: str | Path) -> tuple[list[Asset], dict[str, str]]:
146
158
  for name in FUNCTIONS:
147
159
  if (root / name).exists():
148
160
  raise MakeError(
149
- f"{root / name} is a Pages Functions project",
150
- hint="this module uploads static assets only -- deploy it with wrangler",
161
+ f"{root / name} is Pages Functions code inside the built directory",
162
+ hint="keep `functions/` beside the built directory and pass `functions=`; "
163
+ "an advanced-mode `_worker.js` is not supported",
151
164
  )
152
165
 
153
166
  assets: list[Asset] = []
@@ -260,7 +273,9 @@ def _json_call(method: str, url: str, *, auth: str, payload: object, **kwargs: A
260
273
  )
261
274
 
262
275
 
263
- def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None) -> tuple[bytes, str]:
276
+ def _multipart(
277
+ fields: Mapping[str, str], files: Mapping[str, str | bytes] | None = None
278
+ ) -> tuple[bytes, str]:
264
279
  """Encode a deployment's form: plain fields, then the special files as file parts.
265
280
 
266
281
  ⚠ `_headers` and `_redirects` must be FILE parts (a `filename=` on the part),
@@ -279,7 +294,7 @@ def _multipart(fields: Mapping[str, str], files: Mapping[str, str] | None = None
279
294
  chunks.append(f"--{boundary}\r\n".encode())
280
295
  chunks.append(f'Content-Disposition: form-data; name="{name}"; filename="{name}"\r\n'.encode())
281
296
  chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
282
- chunks.append(value.encode())
297
+ chunks.append(value if isinstance(value, bytes) else value.encode())
283
298
  chunks.append(b"\r\n")
284
299
  chunks.append(f"--{boundary}--\r\n".encode())
285
300
  return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
@@ -299,6 +314,55 @@ def _batches(assets: list[Asset]) -> Iterator[list[Asset]]:
299
314
  yield batch
300
315
 
301
316
 
317
+ # -- Pages Functions -------------------------------------------------------
318
+
319
+
320
+ def _wrangler() -> list[str]:
321
+ found = shutil.which("wrangler")
322
+ return [found] if found else ["npx", "--yes", "wrangler"]
323
+
324
+
325
+ def build_functions(functions: str | Path, directory: str | Path, out: str | Path) -> dict[str, bytes]:
326
+ """Bundle a `functions/` tree; return the deployment's file parts.
327
+
328
+ `_worker.bundle` (the Worker, already in the upload form Pages takes),
329
+ `functions-filepath-routing-config.json`, and `_routes.json` unless the
330
+ built directory brings its own -- the same three `wrangler pages deploy`
331
+ sends. Empty under `--dry-run`, where nothing was built.
332
+ """
333
+ source = Path(functions).resolve()
334
+ root = Path(directory).resolve()
335
+ if not source.is_dir():
336
+ raise MakeError(f"{source} is not a directory", hint="`functions=` names the Pages Functions tree")
337
+ out = Path(out)
338
+ bundle, config, routes = out / "_worker.bundle", out / "routing-config.json", out / "_routes.json"
339
+ sh(
340
+ *_wrangler(),
341
+ "pages",
342
+ "functions",
343
+ "build",
344
+ source,
345
+ "--outfile",
346
+ bundle,
347
+ "--output-config-path",
348
+ config,
349
+ "--output-routes-path",
350
+ routes,
351
+ "--build-output-directory",
352
+ root,
353
+ cwd=source.parent,
354
+ )
355
+ if not bundle.is_file():
356
+ return {}
357
+ parts = {
358
+ "_worker.bundle": bundle.read_bytes(),
359
+ "functions-filepath-routing-config.json": config.read_bytes(),
360
+ }
361
+ if not (root / "_routes.json").is_file() and routes.is_file():
362
+ parts["_routes.json"] = routes.read_bytes()
363
+ return parts
364
+
365
+
302
366
  # -- the tasks' behaviour --------------------------------------------------
303
367
 
304
368
 
@@ -308,6 +372,7 @@ def deploy(
308
372
  project: str,
309
373
  branch: str = "main",
310
374
  keep: int = KEEP,
375
+ functions: str | Path | None = None,
311
376
  account: str | None = None,
312
377
  token: str | None = None,
313
378
  ) -> str:
@@ -318,11 +383,17 @@ def deploy(
318
383
  other name as a preview on its own URL. There is no `--production` flag to
319
384
  forget; there is a branch name to get right.
320
385
 
386
+ `functions` names a Pages Functions tree (`functions/`, beside the built
387
+ directory): it is bundled by `build_functions` and goes up with the
388
+ deployment, so `/api/...` answers from the same project.
389
+
321
390
  Afterwards the project is pruned to its newest `keep` deployments (see
322
391
  `prune`); `keep=0` leaves every one in place.
323
392
  """
324
393
  account, token = _credentials(account, token)
325
394
  assets, specials = collect(directory)
395
+ with tempfile.TemporaryDirectory(prefix="mk-pages-") as scratch:
396
+ worker = build_functions(functions, directory, scratch) if functions is not None else {}
326
397
  plural = "" if len(assets) == 1 else "s"
327
398
  step(f"{len(assets)} file{plural}, {sum(a.size for a in assets) / 1024:.0f} KiB -> {project} ({branch})")
328
399
 
@@ -377,7 +448,7 @@ def deploy(
377
448
  )
378
449
 
379
450
  fields = {"manifest": json.dumps(manifest_of(assets)), "branch": branch}
380
- body, content_type = _multipart(fields, specials)
451
+ body, content_type = _multipart(fields, {**specials, **worker})
381
452
  created = _call(
382
453
  "POST",
383
454
  f"{API}/accounts/{account}/pages/projects/{project}/deployments",
@@ -385,8 +456,8 @@ def deploy(
385
456
  body=body,
386
457
  content_type=content_type,
387
458
  )
388
- if specials:
389
- note("as deployment files, not assets: " + ", ".join(sorted(specials)))
459
+ if specials or worker:
460
+ note("as deployment files, not assets: " + ", ".join(sorted({**specials, **worker})))
390
461
  url = created.get("url") if isinstance(created, dict) else None
391
462
  if keep:
392
463
  prune(project, keep=keep, account=account, token=token)
@@ -109,6 +109,13 @@ def test_a_functions_project_is_refused(tmp_path):
109
109
  pages.collect(tmp_path)
110
110
 
111
111
 
112
+ def test_functions_inside_the_built_directory_are_refused(tmp_path):
113
+ """Served from there, the API's source would be a static file anyone can read."""
114
+ build(tmp_path, {"index.html": "x", "functions/api/x.ts": "export const onRequest = 1"})
115
+ with pytest.raises(MakeError, match="inside the built directory"):
116
+ pages.collect(tmp_path)
117
+
118
+
112
119
  def test_an_oversized_asset_is_named(tmp_path):
113
120
  (tmp_path / "big.bin").write_bytes(b"x" * (pages.MAX_ASSET_SIZE + 1))
114
121
  with pytest.raises(MakeError, match=r"big\.bin"):
@@ -360,3 +367,59 @@ def test_deploy_prunes_unless_told_to_keep_everything(monkeypatch, tmp_path):
360
367
  calls.clear()
361
368
  pages.deploy(tmp_path, project="demo", account="acct", token="tok", keep=0)
362
369
  assert deleted(calls) == [] and not any("/deployments?" in url for _, url in calls)
370
+
371
+
372
+ # -- Pages Functions -------------------------------------------------------
373
+
374
+
375
+ def fake_wrangler(monkeypatch):
376
+ """Stands in for `wrangler pages functions build`, writing the three outputs."""
377
+ seen = []
378
+
379
+ def run(*argv, cwd=None, **kwargs):
380
+ argv = [str(a) for a in argv]
381
+ seen.append(argv)
382
+ out = {
383
+ flag: argv[argv.index(flag) + 1]
384
+ for flag in ("--outfile", "--output-config-path", "--output-routes-path")
385
+ }
386
+ pages.Path(out["--outfile"]).write_bytes(b"--bundle--")
387
+ pages.Path(out["--output-config-path"]).write_text('{"routes": []}')
388
+ pages.Path(out["--output-routes-path"]).write_text('{"include": ["/api/*"]}')
389
+
390
+ monkeypatch.setattr(pages, "sh", run)
391
+ return seen
392
+
393
+
394
+ def test_functions_go_up_as_file_parts_of_the_deployment(monkeypatch, tmp_path):
395
+ site = build(tmp_path / "dist", {"index.html": "x"})
396
+ functions = build(tmp_path / "functions", {"api/[[path]].ts": "export const onRequest = 1"})
397
+ seen = fake_wrangler(monkeypatch)
398
+ fake = FakeHTTP()
399
+ deploy(monkeypatch, site, fake, functions=functions)
400
+
401
+ assert seen[0][1:4] == ["pages", "functions", "build"] or seen[0][3:6] == ["pages", "functions", "build"]
402
+ body = fake.body("/deployments").decode()
403
+ assert 'name="_worker.bundle"; filename="_worker.bundle"' in body and "--bundle--" in body
404
+ assert 'name="functions-filepath-routing-config.json"; filename=' in body
405
+ assert 'name="_routes.json"; filename="_routes.json"' in body and "/api/*" in body
406
+ # The functions are never assets.
407
+ assert "[[path]]" not in fake.body("/check-missing").decode()
408
+
409
+
410
+ def test_the_sites_own_routes_beat_the_generated_ones(monkeypatch, tmp_path):
411
+ site = build(tmp_path / "dist", {"index.html": "x", "_routes.json": '{"include": ["/mine/*"]}'})
412
+ functions = build(tmp_path / "functions", {"api/x.ts": "export const onRequest = 1"})
413
+ fake_wrangler(monkeypatch)
414
+ fake = FakeHTTP()
415
+ deploy(monkeypatch, site, fake, functions=functions)
416
+
417
+ body = fake.body("/deployments").decode()
418
+ assert body.count('filename="_routes.json"') == 1
419
+ assert "/mine/*" in body and "/api/*" not in body
420
+
421
+
422
+ def test_a_missing_functions_tree_is_named(monkeypatch, tmp_path):
423
+ site = build(tmp_path / "dist", {"index.html": "x"})
424
+ with pytest.raises(MakeError, match="not a directory"):
425
+ deploy(monkeypatch, site, FakeHTTP(), functions=tmp_path / "nope")