@jimhoyd/urlcode 0.3.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/CONTRIBUTING.md +80 -0
- package/LICENSE +202 -0
- package/NOTICE +20 -0
- package/README.md +342 -0
- package/ROADMAP.md +248 -0
- package/SECURITY.md +40 -0
- package/data/agents/LICENSES/ai-robots-txt.txt +24 -0
- package/data/agents/LICENSES/crawler-user-agents.txt +23 -0
- package/data/agents/ai-crawlers.json +1240 -0
- package/data/agents/crawlers.json +10515 -0
- package/data/agents/index.d.ts +3 -0
- package/data/agents/index.js +2453 -0
- package/data/agents/monitoring.json +1520 -0
- package/data/agents/seo.json +3865 -0
- package/dist/BUILD-MANIFEST.json +60 -0
- package/dist/adapters.js +59 -0
- package/dist/agent-lists.js +59 -0
- package/dist/assets.js +129 -0
- package/dist/authoring.js +68 -0
- package/dist/aws.js +110 -0
- package/dist/build-cloudflare.js +164 -0
- package/dist/cli.js +185 -0
- package/dist/client-address.js +85 -0
- package/dist/cloudflare.js +165 -0
- package/dist/compliance-rules/baseline.js +98 -0
- package/dist/compliance-rules/privacy.js +38 -0
- package/dist/compliance-rules/shared.js +62 -0
- package/dist/compliance-rules/strict.js +61 -0
- package/dist/compliance.js +219 -0
- package/dist/config-worker.js +9 -0
- package/dist/config.js +172 -0
- package/dist/errors.js +8 -0
- package/dist/function-sources.js +54 -0
- package/dist/function-worker.js +105 -0
- package/dist/functions.js +174 -0
- package/dist/guest-api.js +114 -0
- package/dist/header-validation.js +18 -0
- package/dist/http-policy.js +74 -0
- package/dist/http-response.js +84 -0
- package/dist/index.js +11 -0
- package/dist/link-api.js +136 -0
- package/dist/link-cli.js +141 -0
- package/dist/link-events.js +76 -0
- package/dist/link-records.js +31 -0
- package/dist/link-store-worker.js +150 -0
- package/dist/link-store.js +250 -0
- package/dist/logging.js +22 -0
- package/dist/management-policy.js +41 -0
- package/dist/match.js +124 -0
- package/dist/observability.js +242 -0
- package/dist/plugins.js +74 -0
- package/dist/policies/agents.js +248 -0
- package/dist/policies/cache.js +297 -0
- package/dist/policies/compression.js +187 -0
- package/dist/policies/security.js +0 -0
- package/dist/policies/throttle.js +131 -0
- package/dist/policies.js +142 -0
- package/dist/policy.js +55 -0
- package/dist/prerender.js +165 -0
- package/dist/project-tests.js +23 -0
- package/dist/readiness.js +216 -0
- package/dist/router.js +166 -0
- package/dist/runtime.js +244 -0
- package/dist/scaffold.js +0 -0
- package/dist/scripts/operational-drills.js +75 -0
- package/dist/server.js +270 -0
- package/dist/site.js +0 -0
- package/dist/sqlite-version.js +6 -0
- package/dist/types/adapters.d.ts +12 -0
- package/dist/types/agent-lists.d.ts +5 -0
- package/dist/types/assets.d.ts +13 -0
- package/dist/types/authoring.d.ts +2 -0
- package/dist/types/aws.d.ts +35 -0
- package/dist/types/build-cloudflare.d.ts +14 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/client-address.d.ts +9 -0
- package/dist/types/cloudflare.d.ts +75 -0
- package/dist/types/compliance-rules/baseline.d.ts +12 -0
- package/dist/types/compliance-rules/privacy.d.ts +6 -0
- package/dist/types/compliance-rules/shared.d.ts +24 -0
- package/dist/types/compliance-rules/strict.d.ts +9 -0
- package/dist/types/compliance.d.ts +119 -0
- package/dist/types/config-worker.d.ts +1 -0
- package/dist/types/config.d.ts +21 -0
- package/dist/types/errors.d.ts +7 -0
- package/dist/types/function-sources.d.ts +21 -0
- package/dist/types/function-worker.d.ts +1 -0
- package/dist/types/functions.d.ts +92 -0
- package/dist/types/guest-api.d.ts +16 -0
- package/dist/types/header-validation.d.ts +2 -0
- package/dist/types/http-policy.d.ts +39 -0
- package/dist/types/http-response.d.ts +43 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/link-api.d.ts +30 -0
- package/dist/types/link-cli.d.ts +36 -0
- package/dist/types/link-events.d.ts +27 -0
- package/dist/types/link-records.d.ts +11 -0
- package/dist/types/link-store-worker.d.ts +1 -0
- package/dist/types/link-store.d.ts +130 -0
- package/dist/types/logging.d.ts +9 -0
- package/dist/types/management-policy.d.ts +9 -0
- package/dist/types/match.d.ts +81 -0
- package/dist/types/observability.d.ts +104 -0
- package/dist/types/plugins.d.ts +27 -0
- package/dist/types/policies/agents.d.ts +86 -0
- package/dist/types/policies/cache.d.ts +82 -0
- package/dist/types/policies/compression.d.ts +41 -0
- package/dist/types/policies/security.d.ts +37 -0
- package/dist/types/policies/throttle.d.ts +55 -0
- package/dist/types/policies.d.ts +36 -0
- package/dist/types/policy.d.ts +21 -0
- package/dist/types/prerender.d.ts +31 -0
- package/dist/types/project-tests.d.ts +13 -0
- package/dist/types/readiness.d.ts +121 -0
- package/dist/types/router.d.ts +11 -0
- package/dist/types/runtime.d.ts +91 -0
- package/dist/types/scaffold.d.ts +17 -0
- package/dist/types/server.d.ts +37 -0
- package/dist/types/site.d.ts +12 -0
- package/dist/types/sqlite-version.d.ts +1 -0
- package/dist/types/types.d.ts +332 -0
- package/dist/types/vercel.d.ts +12 -0
- package/dist/types.js +177 -0
- package/dist/vercel.js +65 -0
- package/docs/AI-AUTHORING.md +113 -0
- package/docs/ASSETS.md +106 -0
- package/docs/AWS.md +77 -0
- package/docs/BEST-PRACTICES.md +267 -0
- package/docs/CAPACITY.md +208 -0
- package/docs/CLOUDFLARE.md +110 -0
- package/docs/COMPLIANCE.md +242 -0
- package/docs/DYNAMIC-LINKS.md +561 -0
- package/docs/FUNCTION-SECURITY.md +113 -0
- package/docs/HTTP.md +129 -0
- package/docs/INSTALL.md +90 -0
- package/docs/LOAD-TESTING.md +91 -0
- package/docs/LOCAL-DEVELOPMENT.md +99 -0
- package/docs/MANAGEMENT-SECURITY.md +82 -0
- package/docs/MIDDLEWARE.md +83 -0
- package/docs/MONITORING.md +132 -0
- package/docs/OBSERVABILITY.md +229 -0
- package/docs/OPERATIONAL-PROOF.md +42 -0
- package/docs/OPERATIONS.md +207 -0
- package/docs/ORGANIZATION.md +135 -0
- package/docs/PERFORMANCE.md +72 -0
- package/docs/PLUGINS.md +234 -0
- package/docs/POLICIES.md +489 -0
- package/docs/PRERENDER.md +193 -0
- package/docs/PROJECT-DIRECTION.md +51 -0
- package/docs/READINESS.md +125 -0
- package/docs/README.md +58 -0
- package/docs/RELEASE-READINESS.md +95 -0
- package/docs/RELEASE-SECURITY.md +72 -0
- package/docs/RESILIENCE.md +160 -0
- package/docs/ROUTING.md +95 -0
- package/docs/SANDBOX-REVIEW.md +59 -0
- package/docs/SCAFFOLDING.md +72 -0
- package/docs/SECURITY-AUDIT.md +124 -0
- package/docs/SITE.md +150 -0
- package/docs/SPECIFICATION.md +232 -0
- package/docs/SPIKE-EXTENSIONS.md +475 -0
- package/docs/STANDARDS.md +303 -0
- package/docs/STARTERS.md +51 -0
- package/docs/TUNNELS.md +75 -0
- package/docs/TYPESCRIPT.md +102 -0
- package/docs/VERCEL.md +107 -0
- package/docs/YAML-GUIDE.md +521 -0
- package/docs/YAML-REFERENCE.md +328 -0
- package/docs/policies/agents.md +182 -0
- package/docs/policies/cache.md +139 -0
- package/docs/policies/compression.md +168 -0
- package/docs/policies/security.md +160 -0
- package/docs/policies/throttle.md +103 -0
- package/examples/assets/.env.example +1 -0
- package/examples/assets/.gitattributes +2 -0
- package/examples/assets/Makefile +30 -0
- package/examples/assets/README.md +18 -0
- package/examples/assets/functions/hello.mjs +3 -0
- package/examples/assets/gitignore.template +7 -0
- package/examples/assets/public/about.html +2 -0
- package/examples/assets/public/assets/example.txt +1 -0
- package/examples/assets/public/guide.txt +1 -0
- package/examples/assets/tests/requests.json +44 -0
- package/examples/assets/urlcode.yaml +27 -0
- package/examples/aws/README.md +13 -0
- package/examples/aws/handler.mjs +5 -0
- package/examples/aws/public/index.html +3 -0
- package/examples/aws/public/notes.txt +1 -0
- package/examples/aws/template.yaml +33 -0
- package/examples/aws/tests/requests.json +9 -0
- package/examples/aws/urlcode.yaml +19 -0
- package/examples/cloudflare/README.md +18 -0
- package/examples/cloudflare/package.json +14 -0
- package/examples/cloudflare/tests/requests.json +9 -0
- package/examples/cloudflare/urlcode.yaml +24 -0
- package/examples/cloudflare/wrangler.toml +5 -0
- package/examples/compliance/README.md +20 -0
- package/examples/compliance/rules.mjs +65 -0
- package/examples/cookbook/README.md +21 -0
- package/examples/cookbook/functions/choice.mjs +4 -0
- package/examples/cookbook/functions/echo.mjs +3 -0
- package/examples/cookbook/functions/hello.mjs +3 -0
- package/examples/cookbook/functions/text.mjs +3 -0
- package/examples/cookbook/middleware/headers.mjs +6 -0
- package/examples/cookbook/public/about.html +1 -0
- package/examples/cookbook/public/assets/index.html +1 -0
- package/examples/cookbook/public/assets/site.css +1 -0
- package/examples/cookbook/public/favicon.svg +1 -0
- package/examples/cookbook/public/guide.txt +1 -0
- package/examples/cookbook/public/llms.txt +6 -0
- package/examples/cookbook/routes/code.yaml +55 -0
- package/examples/cookbook/routes/files.yaml +17 -0
- package/examples/cookbook/routes/policies.yaml +28 -0
- package/examples/cookbook/routes/redirects.yaml +38 -0
- package/examples/cookbook/routes/responses.yaml +26 -0
- package/examples/cookbook/tests/requests.json +254 -0
- package/examples/cookbook/urlcode.yaml +22 -0
- package/examples/live-links/README.md +11 -0
- package/examples/live-links/tests/requests.json +6 -0
- package/examples/live-links/urlcode.yaml +16 -0
- package/examples/monitoring/blackbox-jobs.yaml +37 -0
- package/examples/monitoring/prometheus-rules.yaml +88 -0
- package/examples/monitoring/prometheus-scrape.yaml +20 -0
- package/examples/monitoring/vector.toml +65 -0
- package/examples/prerender/README.md +48 -0
- package/examples/prerender/functions/page.mjs +6 -0
- package/examples/prerender/middleware/template.mjs +29 -0
- package/examples/prerender/prerender.d.mts +4 -0
- package/examples/prerender/prerender.mjs +63 -0
- package/examples/prerender/tests/requests.json +39 -0
- package/examples/prerender/urlcode.yaml +50 -0
- package/examples/tunnel/dev-with-ngrok.sh +61 -0
- package/examples/vercel/README.md +13 -0
- package/examples/vercel/api/index.js +5 -0
- package/examples/vercel/package.json +8 -0
- package/examples/vercel/public/index.html +3 -0
- package/examples/vercel/public/notes.txt +1 -0
- package/examples/vercel/tests/requests.json +48 -0
- package/examples/vercel/urlcode.yaml +19 -0
- package/examples/vercel/vercel.json +10 -0
- package/llms.txt +46 -0
- package/package.json +123 -0
- package/schemas/urlcode.schema.json +1230 -0
- package/starters/default/.gitattributes +1 -0
- package/starters/default/Makefile +30 -0
- package/starters/default/README.md +34 -0
- package/starters/default/functions/hello.mjs +3 -0
- package/starters/default/gitignore.template +8 -0
- package/starters/default/middleware/headers.mjs +6 -0
- package/starters/default/routes/functions.yaml +20 -0
- package/starters/default/routes/marketing/links.yaml +7 -0
- package/starters/default/starter.json +5 -0
- package/starters/default/tests/requests.json +56 -0
- package/starters/default/urlcode.yaml +8 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Compression policy
|
|
2
|
+
|
|
3
|
+
`policies.compression` negotiates a content coding (RFC 9110 §12.5.3) on the
|
|
4
|
+
host, outside the sandbox, for every result a route produces: `respond`
|
|
5
|
+
bodies, function results, asset responses, cache hits and early denials.
|
|
6
|
+
It runs last in the response phase, after the cache store and the security
|
|
7
|
+
headers, so every header it reads is final. Asset snapshots are compressed
|
|
8
|
+
once at load and served by reference (the NGINX `gzip_static` / Caddy
|
|
9
|
+
`precompressed` model); other bodies are compressed on the request path
|
|
10
|
+
within a fixed size bound.
|
|
11
|
+
|
|
12
|
+
## YAML
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
version: "1"
|
|
16
|
+
policies:
|
|
17
|
+
compression:
|
|
18
|
+
encodings: [br, gzip] # preference order; br | gzip | deflate | zstd
|
|
19
|
+
minBytes: 1024 # bodies below this stay identity (default 1024)
|
|
20
|
+
types: # media types eligible; `type/*` wildcards allowed
|
|
21
|
+
- text/*
|
|
22
|
+
- application/json
|
|
23
|
+
- application/javascript
|
|
24
|
+
- application/xml
|
|
25
|
+
- image/svg+xml
|
|
26
|
+
- application/manifest+json
|
|
27
|
+
- application/ld+json
|
|
28
|
+
level: 6 # 1–11, optional; mapped per codec (table below)
|
|
29
|
+
allowWithSecrets: false # compress even where BREACH applies (default false)
|
|
30
|
+
|
|
31
|
+
routes:
|
|
32
|
+
/api/report:
|
|
33
|
+
function: { source: functions/report.mjs }
|
|
34
|
+
secrets: { KEY: { secret: api-key } }
|
|
35
|
+
policies:
|
|
36
|
+
compression: { allowWithSecrets: true } # route keys merge over project keys
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`encodings`, `minBytes`, `types`, `level` and `allowWithSecrets` are the
|
|
40
|
+
only keys; the list above is the default `types`. `zstd` is accepted only
|
|
41
|
+
when the running Node exposes `zlib.zstdCompressSync` (Node 22.15+ / 23.8+);
|
|
42
|
+
otherwise startup fails with the route named, rather than silently serving
|
|
43
|
+
identity. YAML `response.headers` cannot set `Content-Encoding` (it is a
|
|
44
|
+
runtime-owned header); this policy is the only thing that sets it.
|
|
45
|
+
|
|
46
|
+
### Levels
|
|
47
|
+
|
|
48
|
+
Without `level`, each codec uses a latency-leaning level for request-path
|
|
49
|
+
bodies and a size-leaning level for asset variants that are computed once.
|
|
50
|
+
With `level`, one number in YAML is mapped onto each codec's own scale and
|
|
51
|
+
applies to both paths.
|
|
52
|
+
|
|
53
|
+
| Coding | Dynamic default | Stored default | `level` mapping |
|
|
54
|
+
|---|---|---|---|
|
|
55
|
+
| `br` | quality 4 | quality 9 | `level` (1–11) |
|
|
56
|
+
| `gzip` | 6 | 9 | `min(level, 9)` |
|
|
57
|
+
| `deflate` | 6 | 9 | `min(level, 9)` |
|
|
58
|
+
| `zstd` | 3 | 12 | `min(level × 2, 22)` |
|
|
59
|
+
|
|
60
|
+
## Negotiation
|
|
61
|
+
|
|
62
|
+
The `Accept-Encoding` field is parsed per RFC 9110: each coding carries a
|
|
63
|
+
`q` weight (default 1, `q=0` means not acceptable), `*` supplies the weight
|
|
64
|
+
of any coding not named, and a coding neither named nor covered by `*` is
|
|
65
|
+
not acceptable. The acceptable coding in `encodings` with the highest weight
|
|
66
|
+
wins; equal weights are broken by the order of `encodings`, so
|
|
67
|
+
`Accept-Encoding: gzip, br` selects `br` when the project lists `br` first.
|
|
68
|
+
When nothing in `encodings` is acceptable, including `identity;q=0` or
|
|
69
|
+
`*;q=0`, the response is sent as identity; a 406 helps nobody.
|
|
70
|
+
|
|
71
|
+
Every response whose media type is in `types` gets `Vary: Accept-Encoding`,
|
|
72
|
+
merged into an existing `Vary` without duplication and preserved on 304 and
|
|
73
|
+
206, so a shared cache keys the URL by the field even when this particular
|
|
74
|
+
response was not compressed.
|
|
75
|
+
|
|
76
|
+
## When compression is skipped
|
|
77
|
+
|
|
78
|
+
The response is sent as identity, still with `Vary`, when any of these hold:
|
|
79
|
+
|
|
80
|
+
- the status is 206 (a byte range of the identity representation), 304, 204
|
|
81
|
+
or 205, or the result already carries `Content-Encoding`;
|
|
82
|
+
- `Cache-Control` contains `no-transform`;
|
|
83
|
+
- the media type is not in `types`, or the body is below `minBytes`;
|
|
84
|
+
- the request method is `HEAD` and the body has no precomputed variant (see
|
|
85
|
+
below);
|
|
86
|
+
- the body exceeds 1 MiB and has no precomputed variant: a synchronous
|
|
87
|
+
compression of that size would hold the event loop for every other
|
|
88
|
+
request, so such bodies stay identity on the request path;
|
|
89
|
+
- compressing did not shrink the body;
|
|
90
|
+
- **BREACH:** the route declares `secrets`, or the response carries
|
|
91
|
+
`Set-Cookie`, unless `allowWithSecrets: true`.
|
|
92
|
+
|
|
93
|
+
### BREACH rationale
|
|
94
|
+
|
|
95
|
+
Compressing a body that mixes a secret (a session token, a CSRF token, an
|
|
96
|
+
API key echoed into a page) with attacker-influenced input leaks the secret
|
|
97
|
+
through the compressed length: an attacker who can make the victim's
|
|
98
|
+
browser issue requests with chosen input measures which guesses shrink the
|
|
99
|
+
response. The signal is only there when secret and input share a
|
|
100
|
+
compressed body, so the policy refuses to compress exactly where a secret is
|
|
101
|
+
plausible: a route that has been granted secrets, and any response that
|
|
102
|
+
sets a cookie. `allowWithSecrets: true` is for routes whose bodies do not
|
|
103
|
+
echo the secret (a route that uses a key to call an upstream API and returns
|
|
104
|
+
public data); set it per route, not for the project.
|
|
105
|
+
|
|
106
|
+
## ETag and HEAD
|
|
107
|
+
|
|
108
|
+
RFC 9110 requires a strong validator to differ between representations, so
|
|
109
|
+
an encoded body cannot carry the identity ETag unchanged. The policy handles
|
|
110
|
+
the two paths differently:
|
|
111
|
+
|
|
112
|
+
- **Precompressed assets** keep a strong ETag with the coding appended
|
|
113
|
+
inside the quotes: `"<sha256>-br"`, `"<sha256>-gz"`, `"<sha256>-df"`,
|
|
114
|
+
`"<sha256>-zs"`. The asset handler validates `If-None-Match` against the
|
|
115
|
+
identity tag; the policy validates the suffixed tag for the coding it
|
|
116
|
+
selected and answers 304 (with the suffixed ETag and `Vary`) when it
|
|
117
|
+
matches. A suffixed tag presented with a different `Accept-Encoding`
|
|
118
|
+
selects a different representation and gets a fresh 200. `If-Range` only
|
|
119
|
+
ever matches the identity tag, so a range request against a variant tag
|
|
120
|
+
gets the full identity body, as the RFC prescribes for a non-matching
|
|
121
|
+
validator.
|
|
122
|
+
- **Dynamically compressed bodies** (functions, `respond`, middleware
|
|
123
|
+
results, cache hits) keep the handler's ETag but weakened: `W/"v1"`. Weak
|
|
124
|
+
comparison treats `W/"v1"` and `"v1"` as equal, so a client revalidating
|
|
125
|
+
with the weak tag gets the same 304 it would for identity, and a
|
|
126
|
+
handler's own `If-None-Match` logic keeps working. Nothing is appended,
|
|
127
|
+
because a dynamic body has no stable bytes for a strong tag to name.
|
|
128
|
+
|
|
129
|
+
`HEAD` reports what `GET` would send when the answer is free: a
|
|
130
|
+
precompressed asset answers `HEAD` with `Content-Encoding` and the variant's
|
|
131
|
+
`Content-Length`. A dynamic body is not compressed for `HEAD` (it would pay
|
|
132
|
+
the whole compression for one number) and reports the identity length,
|
|
133
|
+
the same `Content-Length` its `GET` would carry uncompressed.
|
|
134
|
+
|
|
135
|
+
## Precompression and memory bounds
|
|
136
|
+
|
|
137
|
+
`compileAssets` runs before policies compile, so the policy, not the asset
|
|
138
|
+
loader, computes variants: in `compile()` it walks the route's asset
|
|
139
|
+
snapshot (one file for `page`/`download`, the whole tree for `static`) and
|
|
140
|
+
compresses every file whose type is in `types` and whose size is at least
|
|
141
|
+
`minBytes`, once per configured coding. A project without the policy pays
|
|
142
|
+
nothing. Variants are stored on the immutable snapshot and replaced with it
|
|
143
|
+
on reload. Bounds:
|
|
144
|
+
|
|
145
|
+
- a variant at least as large as the original is dropped;
|
|
146
|
+
- the aggregate of all variants across the runtime is capped at 64 MiB
|
|
147
|
+
(the same figure as the asset snapshot itself); beyond it, remaining
|
|
148
|
+
files are served identity or compressed on the request path when they
|
|
149
|
+
fit the 1 MiB dynamic bound;
|
|
150
|
+
- a request for a stored variant costs one buffer reference, no copy.
|
|
151
|
+
|
|
152
|
+
`urlcode audit` and `testPlan()` report `precompressed`, the number of
|
|
153
|
+
variants a route holds, alongside `encodings`, `minBytes`, the count of
|
|
154
|
+
`types` and `level`.
|
|
155
|
+
|
|
156
|
+
## Per-target behavior
|
|
157
|
+
|
|
158
|
+
| Target | Support | Notes |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| node | native | Negotiation, precompressed assets and dynamic compression as described. |
|
|
161
|
+
| vercel | delegated | The platform compresses responses at its edge; the policy is accepted and dropped so one YAML serves every host. |
|
|
162
|
+
| aws | delegated | CloudFront/API Gateway compression is configured on the platform; the policy is accepted and dropped. |
|
|
163
|
+
| cloudflare | delegated | Workers responses are compressed by the Cloudflare edge; the build accepts and drops the policy. |
|
|
164
|
+
|
|
165
|
+
A refusal is deliberate: the YAML stays portable and the difference is
|
|
166
|
+
visible at build time rather than as a silent double compression. Remove
|
|
167
|
+
the key, or set `compression: false` on the routes that use a profile which
|
|
168
|
+
declares it, when deploying to those targets.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Security headers policy
|
|
2
|
+
|
|
3
|
+
`policies.security` adds response security headers on the host, outside the
|
|
4
|
+
sandbox, to every result a route produces: redirects, `respond` bodies,
|
|
5
|
+
function results, asset responses, cache hits and early denials from the
|
|
6
|
+
`agents` and `throttle` policies. Values are the OWASP Secure Headers
|
|
7
|
+
Project best-practice recommendations (OSHP 2024 best practices,
|
|
8
|
+
<https://owasp.org/www-project-secure-headers/>). The tables below are
|
|
9
|
+
generated from the frozen `profiles` constant in `src/policies/security.ts`.
|
|
10
|
+
|
|
11
|
+
## YAML
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
version: "1"
|
|
15
|
+
policies:
|
|
16
|
+
security:
|
|
17
|
+
headers: oshp # oshp (default) | oshp-no-csp | off
|
|
18
|
+
set: # add or overwrite one header, verbatim
|
|
19
|
+
Content-Security-Policy-Report-Only: "default-src 'self'; report-to csp"
|
|
20
|
+
Clear-Site-Data: '"cache"'
|
|
21
|
+
unset: # drop a header the profile would emit
|
|
22
|
+
- Cross-Origin-Embedder-Policy
|
|
23
|
+
routes:
|
|
24
|
+
/embed:
|
|
25
|
+
respond: { text: ok }
|
|
26
|
+
policies:
|
|
27
|
+
security: { headers: oshp-no-csp } # route keys merge over project keys
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`headers`, `set` and `unset` are the only keys. A route may also write
|
|
31
|
+
`security: false` to disable the policy for that route.
|
|
32
|
+
|
|
33
|
+
## Profile `oshp`
|
|
34
|
+
|
|
35
|
+
| Header | Value |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `strict-transport-security` | `max-age=31536000; includeSubDomains` |
|
|
38
|
+
| `x-frame-options` | `deny` |
|
|
39
|
+
| `x-permitted-cross-domain-policies` | `none` |
|
|
40
|
+
| `referrer-policy` | `strict-origin-when-cross-origin` |
|
|
41
|
+
| `content-security-policy` | `default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content` |
|
|
42
|
+
| `cross-origin-embedder-policy` | `require-corp` |
|
|
43
|
+
| `cross-origin-opener-policy` | `same-origin` |
|
|
44
|
+
| `cross-origin-resource-policy` | `same-origin` |
|
|
45
|
+
| `permissions-policy` | `accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()` |
|
|
46
|
+
|
|
47
|
+
## Profile `oshp-no-csp`
|
|
48
|
+
|
|
49
|
+
Identical to `oshp` without `Content-Security-Policy`.
|
|
50
|
+
|
|
51
|
+
| Header | Value |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `strict-transport-security` | `max-age=31536000; includeSubDomains` |
|
|
54
|
+
| `x-frame-options` | `deny` |
|
|
55
|
+
| `x-permitted-cross-domain-policies` | `none` |
|
|
56
|
+
| `referrer-policy` | `strict-origin-when-cross-origin` |
|
|
57
|
+
| `cross-origin-embedder-policy` | `require-corp` |
|
|
58
|
+
| `cross-origin-opener-policy` | `same-origin` |
|
|
59
|
+
| `cross-origin-resource-policy` | `same-origin` |
|
|
60
|
+
| `permissions-policy` | `accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()` |
|
|
61
|
+
|
|
62
|
+
## Profile `off`
|
|
63
|
+
|
|
64
|
+
Emits nothing. `set` still applies, so `off` plus `set` is a hand-written
|
|
65
|
+
header list.
|
|
66
|
+
|
|
67
|
+
Not in any profile:
|
|
68
|
+
|
|
69
|
+
- `X-Content-Type-Options: nosniff`: the runtime sets it on every response,
|
|
70
|
+
including errors, so the profile does not duplicate it.
|
|
71
|
+
- `Cache-Control`: owned by the `cache` policy and the asset handlers;
|
|
72
|
+
`set` refuses it.
|
|
73
|
+
- `Clear-Site-Data`: destructive by design, so only on explicit `set`.
|
|
74
|
+
|
|
75
|
+
## Precedence
|
|
76
|
+
|
|
77
|
+
1. Headers already on the result keep their value. YAML `response.headers`
|
|
78
|
+
are applied by the runtime before this policy runs, and a function or asset
|
|
79
|
+
handler sets its headers earlier still, so any of them beats the profile
|
|
80
|
+
header by header (`x-frame-options: sameorigin` on a route wins over the
|
|
81
|
+
profile's `deny`).
|
|
82
|
+
2. `set` overwrites everything: the profile and an existing header of the
|
|
83
|
+
same name. Writing a header under `set` is explicit operator intent.
|
|
84
|
+
3. `unset` removes a header from the profile by case-insensitive name. Naming
|
|
85
|
+
a header the selected profile does not emit is a configuration error that
|
|
86
|
+
names the route, so a typo cannot silently leave a header in place. The
|
|
87
|
+
check runs against the profile in effect on each route: a route that
|
|
88
|
+
switches to a profile without that header inherits the project `unset`
|
|
89
|
+
and must write `unset: []` to clear it.
|
|
90
|
+
|
|
91
|
+
`set` names and values are validated at activation with the same rules the
|
|
92
|
+
wire enforces (RFC 7230 token names, no control characters) and may not name a
|
|
93
|
+
header the runtime or a handler owns (`content-length`, `content-type`,
|
|
94
|
+
`location`, `etag`, `content-encoding`, `cache-control`, `set-cookie`,
|
|
95
|
+
`x-request-id`, `x-content-type-options`, hop-by-hop headers), nor one
|
|
96
|
+
another policy emits (`vary`, `ratelimit`, `ratelimit-policy`, `retry-after`,
|
|
97
|
+
`age`). The static
|
|
98
|
+
headers of one route are capped at 8 KiB so the response keeps room under the
|
|
99
|
+
runtime's 16 KiB / 256-header limit; the error names the route.
|
|
100
|
+
|
|
101
|
+
## Error responses
|
|
102
|
+
|
|
103
|
+
Errors the runtime throws (404, 410, 413 and the rest) do not run the
|
|
104
|
+
response phase, but they do get this policy: the matched route's effective
|
|
105
|
+
profile when the error came after routing, otherwise the project-level one,
|
|
106
|
+
on every host and in the Cloudflare Worker. The fixed error headers
|
|
107
|
+
(`Content-Type`, `Cache-Control: no-store`, `Content-Length`, `X-Request-Id`,
|
|
108
|
+
`X-Content-Type-Options`) are never replaced.
|
|
109
|
+
|
|
110
|
+
## HSTS and the origin
|
|
111
|
+
|
|
112
|
+
`Strict-Transport-Security` is emitted only when the request origin is
|
|
113
|
+
`https:`. On the self-hosted server that is the `--origin` setting (or the
|
|
114
|
+
`origin` option of `startServer`); the Worker uses the request URL. A
|
|
115
|
+
browser ignores HSTS on a plain-text response, and a forwarded header such as
|
|
116
|
+
`X-Forwarded-Proto` is client-controlled, so the runtime never infers the
|
|
117
|
+
scheme from it. Behind a TLS-terminating proxy or tunnel, state
|
|
118
|
+
`--origin https://your.host` and the header appears. Once emitted, HSTS
|
|
119
|
+
commits the host to HTTPS for a year including subdomains: set the origin only
|
|
120
|
+
when that is true.
|
|
121
|
+
|
|
122
|
+
## CSP and pages with inline scripts
|
|
123
|
+
|
|
124
|
+
The `oshp` CSP (`default-src 'self'`, no `unsafe-inline`) blocks inline
|
|
125
|
+
`<script>` and `<style>` blocks and any third-party script. For a page that
|
|
126
|
+
needs them, either use `oshp-no-csp` on that route, or keep the profile and
|
|
127
|
+
trial a policy in report-only mode first:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
policies:
|
|
131
|
+
security:
|
|
132
|
+
headers: oshp-no-csp
|
|
133
|
+
set:
|
|
134
|
+
Content-Security-Policy-Report-Only: "default-src 'self'; script-src 'self' https://cdn.example; report-to csp"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Once the reports are clean, move the value to `Content-Security-Policy` under
|
|
138
|
+
`set`, which overrides the profile value.
|
|
139
|
+
|
|
140
|
+
## Targets
|
|
141
|
+
|
|
142
|
+
| Target | Support | Notes |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| node | native | `--origin` decides HSTS |
|
|
145
|
+
| vercel | native | origin from the adapter's public URL |
|
|
146
|
+
| aws | native | origin from the adapter's public URL |
|
|
147
|
+
| cloudflare | compiled | Validated at build; the Worker compiles the same module synchronously and emits identical headers. HSTS follows the request URL scheme. |
|
|
148
|
+
|
|
149
|
+
## Interaction with other policies
|
|
150
|
+
|
|
151
|
+
- `cache`: security headers are added after the cache store and on cache
|
|
152
|
+
hits, so a stored body never carries a stale profile; `Cache-Control` is
|
|
153
|
+
never touched.
|
|
154
|
+
- `compression`: runs after this policy, so `Vary`/`Content-Encoding` are
|
|
155
|
+
unaffected and the size check above already includes the profile.
|
|
156
|
+
- `agents` and `throttle`: their denials pass through this policy, so a 403
|
|
157
|
+
or 429 carries the same headers as a normal response.
|
|
158
|
+
|
|
159
|
+
`urlcode audit` and `testPlan().policies` report, per route, the profile
|
|
160
|
+
name, the header names it emits, and the `set` and `unset` names.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Throttle policy
|
|
2
|
+
|
|
3
|
+
`policies.throttle` gives each client, route or client-route pair a request
|
|
4
|
+
budget: `quota` requests per `window` seconds. The runtime counts requests in
|
|
5
|
+
a sliding window (two fixed windows blended by elapsed time, the usual middle
|
|
6
|
+
ground between a fixed window that bursts at its edges and a token bucket
|
|
7
|
+
that is hard to explain in a header) and refuses the request that would
|
|
8
|
+
exceed the budget before it reads a body or reaches the sandbox. Refused
|
|
9
|
+
requests are not counted, so a retrying client cannot keep its own window
|
|
10
|
+
from clearing.
|
|
11
|
+
|
|
12
|
+
```yaml
|
|
13
|
+
version: "1"
|
|
14
|
+
policies:
|
|
15
|
+
throttle:
|
|
16
|
+
quota: 120 # requests
|
|
17
|
+
window: 60 # seconds
|
|
18
|
+
partition: client # client | route | client-route (default client)
|
|
19
|
+
status: 429 # 4xx or 5xx answered on refusal (default 429)
|
|
20
|
+
mode: enforce # enforce | report (default enforce)
|
|
21
|
+
maxKeys: 100000 # bounded counter table, LRU eviction
|
|
22
|
+
|
|
23
|
+
routes:
|
|
24
|
+
/api/lookup/{id}:
|
|
25
|
+
function: { source: functions/lookup.mjs }
|
|
26
|
+
policies:
|
|
27
|
+
throttle: { quota: 10, window: 60 } # tighter budget with its own counter
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A route override that restates `quota` and `window` gets its own counters; a
|
|
31
|
+
route that inherits the project budget shares the client's counter with every
|
|
32
|
+
other route on the same budget. `throttle: false` on a route turns it off there.
|
|
33
|
+
|
|
34
|
+
## Standards
|
|
35
|
+
|
|
36
|
+
- [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585) status 429 by default;
|
|
37
|
+
any 4xx or 5xx `status` is accepted and the body is a short plain-text line.
|
|
38
|
+
- [RFC 9110 `Retry-After`](https://www.rfc-editor.org/rfc/rfc9110#field.retry-after),
|
|
39
|
+
integer seconds, on every refusal.
|
|
40
|
+
- [IETF httpapi RateLimit header fields](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
|
|
41
|
+
on every response, allowed or refused, in structured-field syntax:
|
|
42
|
+
`RateLimit-Policy: "default";q=<quota>;w=<window>` and
|
|
43
|
+
`RateLimit: "default";r=<remaining>;t=<seconds until reset>`.
|
|
44
|
+
|
|
45
|
+
A refusal also carries `content-type: text/plain; charset=utf-8` and
|
|
46
|
+
`cache-control: no-store` so no intermediary keeps it.
|
|
47
|
+
|
|
48
|
+
## Targets
|
|
49
|
+
|
|
50
|
+
| Target | `partition: route` | `client` / `client-route` |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| node (`urlcode serve`) | native | native |
|
|
53
|
+
| vercel | native, per instance | refused at activation |
|
|
54
|
+
| aws | native, per instance | refused at activation |
|
|
55
|
+
| cloudflare | refused | refused |
|
|
56
|
+
|
|
57
|
+
Counters live in the process. On serverless targets a route counter is at
|
|
58
|
+
least an honest per-instance ceiling, while a client counter would silently
|
|
59
|
+
become `quota × instances`, so those partitions are refused with the route
|
|
60
|
+
named. The Cloudflare build refuses the policy; map the same `quota` and
|
|
61
|
+
`window` to a provider rate rule instead.
|
|
62
|
+
|
|
63
|
+
## Client identity
|
|
64
|
+
|
|
65
|
+
`client` is the socket peer address. Behind a load balancer every request
|
|
66
|
+
would share one address, so name the proxies allowed to speak for a client:
|
|
67
|
+
`urlcode serve --trusted-proxies 10.0.0.0/8,::1`. Only then is
|
|
68
|
+
`X-Forwarded-For` consulted, walking from the right past trusted hops. When no
|
|
69
|
+
client can be resolved (a caller that passed none, an adapter without a peer)
|
|
70
|
+
requests share a single bucket rather than escaping the budget; the policy
|
|
71
|
+
inventory (`urlcode audit`, `testPlan().policies`) reports this as
|
|
72
|
+
`unresolvedClient: "shared key"`.
|
|
73
|
+
|
|
74
|
+
## Report before enforce
|
|
75
|
+
|
|
76
|
+
Run a release with `mode: report`: headers are emitted and every request logs
|
|
77
|
+
`{ event: "throttle", route, outcome: "allowed" | "exceeded", remaining }`
|
|
78
|
+
(never the client address), but nothing is refused. Read the `exceeded` lines
|
|
79
|
+
against real traffic, set `quota` from what you saw, then switch to `enforce`.
|
|
80
|
+
In enforce mode only `exceeded` is logged.
|
|
81
|
+
|
|
82
|
+
## What it does not do
|
|
83
|
+
|
|
84
|
+
- No shared state across processes or instances. Two `urlcode serve`
|
|
85
|
+
processes behind one balancer each enforce the full budget. A host plugin
|
|
86
|
+
(`onRequest` returning a refusal, backed by whatever store you run) is the
|
|
87
|
+
place for a cluster-wide budget; see the plugin contract in
|
|
88
|
+
`docs/SPIKE-EXTENSIONS.md`.
|
|
89
|
+
- No per-user or per-token keys. The partition is address or route; a token
|
|
90
|
+
bucket per API key is again a plugin.
|
|
91
|
+
- Counters do not survive a reload: a new snapshot starts empty.
|
|
92
|
+
- `maxKeys` bounds memory with least-recently-used eviction; an evicted key
|
|
93
|
+
starts fresh, so a table sized below the number of concurrent clients
|
|
94
|
+
under-counts rather than blocks.
|
|
95
|
+
|
|
96
|
+
## Interaction with other policies
|
|
97
|
+
|
|
98
|
+
Request order is agents, throttle, cache. A denied agent never touches a
|
|
99
|
+
counter; a throttled request never reaches the cache lookup or the handler.
|
|
100
|
+
Security headers and compression still apply to a refusal. A cache hit skips
|
|
101
|
+
only the cache's own response hook, so a cached response still carries the
|
|
102
|
+
client's `RateLimit` headers; the request was counted. A 405 is counted too
|
|
103
|
+
and carries them.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Add names and empty placeholders only. Put local values in ignored .env.local.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.DEFAULT_GOAL := help
|
|
2
|
+
|
|
3
|
+
# Install the runtime separately, or override: URLCODE='node /path/to/urlcode/src/cli.js'
|
|
4
|
+
URLCODE ?= urlcode
|
|
5
|
+
HOST ?= 127.0.0.1
|
|
6
|
+
PORT ?= 3000
|
|
7
|
+
|
|
8
|
+
.PHONY: help dev serve validate test doctor
|
|
9
|
+
help:
|
|
10
|
+
@echo "make dev Run this app with reload and .env.local"
|
|
11
|
+
@echo "make validate Validate this app and its local bindings"
|
|
12
|
+
@echo "make test Run this app's HTTP assertions"
|
|
13
|
+
@echo "make serve Run a fixed snapshot, without local dotenv"
|
|
14
|
+
@echo "make doctor Show runtime/platform details"
|
|
15
|
+
@echo "Options: PORT=3001 HOST=127.0.0.1 URLCODE=urlcode"
|
|
16
|
+
|
|
17
|
+
dev:
|
|
18
|
+
$(URLCODE) dev --project . --host "$(HOST)" --port "$(PORT)"
|
|
19
|
+
serve:
|
|
20
|
+
$(URLCODE) serve --project . --host "$(HOST)" --port "$(PORT)"
|
|
21
|
+
validate:
|
|
22
|
+
$(URLCODE) validate --local --project .
|
|
23
|
+
test:
|
|
24
|
+
$(URLCODE) test --project .
|
|
25
|
+
doctor:
|
|
26
|
+
$(URLCODE) doctor
|
|
27
|
+
|
|
28
|
+
.PHONY: routes audit benchmark
|
|
29
|
+
routes audit benchmark:
|
|
30
|
+
$(URLCODE) $@ --project . $(ARGS)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Asset demonstration
|
|
2
|
+
|
|
3
|
+
This is a runnable example, not a separate starter choice. From the runtime checkout:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
make dev PROJECT=examples/assets
|
|
7
|
+
# In another terminal:
|
|
8
|
+
make test-project PROJECT=examples/assets
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Try `/hello/Ada` for a function, `/go` for a redirect, `/about` for HTML,
|
|
12
|
+
`/assets/example.txt` for a static file, and `/download` for an attachment.
|
|
13
|
+
Assets live in `public/`; never put secrets there. See the
|
|
14
|
+
[asset guide](../../docs/ASSETS.md) for MIME, caching and resource limits.
|
|
15
|
+
|
|
16
|
+
To start your own small app, use `urlcode init ../my-links`. It creates one
|
|
17
|
+
function route and one regular redirect. Add asset routes when you need them.
|
|
18
|
+
URLCode is licensed under the Apache License 2.0.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
A native static asset.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
URLCode: URLs that run code.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"path": "/go",
|
|
4
|
+
"status": 302,
|
|
5
|
+
"expectHeaders": {
|
|
6
|
+
"location": "https://example.com/"
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "/hello/Ada",
|
|
11
|
+
"status": 200,
|
|
12
|
+
"expectBody": "{\"message\":\"Hello, Ada!\"}"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "/hello/Ada",
|
|
16
|
+
"method": "HEAD",
|
|
17
|
+
"status": 200,
|
|
18
|
+
"expectBody": ""
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "/missing",
|
|
22
|
+
"status": 404
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "/about",
|
|
26
|
+
"status": 200,
|
|
27
|
+
"expectHeaders": {
|
|
28
|
+
"content-type": "text/html; charset=utf-8"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"path": "/download",
|
|
33
|
+
"status": 200,
|
|
34
|
+
"expectHeaders": {
|
|
35
|
+
"content-disposition": "attachment; filename=urlcode-guide.txt"
|
|
36
|
+
},
|
|
37
|
+
"expectBody": "URLCode: URLs that run code.\n"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"path": "/assets/example.txt",
|
|
41
|
+
"status": 200,
|
|
42
|
+
"expectBody": "A native static asset.\n"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
version: "1"
|
|
2
|
+
routes:
|
|
3
|
+
/hello/{name}:
|
|
4
|
+
parameters:
|
|
5
|
+
- name: name
|
|
6
|
+
in: path
|
|
7
|
+
required: true
|
|
8
|
+
schema: {type: string, minLength: 1, maxLength: 80}
|
|
9
|
+
function:
|
|
10
|
+
source: functions/hello.mjs
|
|
11
|
+
args:
|
|
12
|
+
name: {from: path, name: name}
|
|
13
|
+
env:
|
|
14
|
+
GREETING: {value: Hello}
|
|
15
|
+
/go:
|
|
16
|
+
redirect:
|
|
17
|
+
url: https://example.com
|
|
18
|
+
/about:
|
|
19
|
+
page:
|
|
20
|
+
file: public/about.html
|
|
21
|
+
/assets/*:
|
|
22
|
+
static:
|
|
23
|
+
directory: public/assets
|
|
24
|
+
/download:
|
|
25
|
+
download:
|
|
26
|
+
file: public/guide.txt
|
|
27
|
+
filename: urlcode-guide.txt
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# URLCode on AWS Lambda
|
|
2
|
+
|
|
3
|
+
A native-handler project served by a Lambda Function URL. See
|
|
4
|
+
[the adapter guide](../../docs/AWS.md) for what is and is not supported.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
urlcode test --project . # the same assertions run locally
|
|
8
|
+
sam deploy --guided
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`handler.mjs` serves the project with `createLambdaHandler`. The deployment
|
|
12
|
+
package must contain the project files the routes read: the entry YAML, any
|
|
13
|
+
includes, and every page, download and static directory.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Lambda entry point. The runtime activates once per execution environment and
|
|
2
|
+
// is reused across warm invocations.
|
|
3
|
+
import { createLambdaHandler } from '@jimhoyd/urlcode/aws';
|
|
4
|
+
|
|
5
|
+
export const handler = createLambdaHandler({ project: process.env.LAMBDA_TASK_ROOT ?? process.cwd() });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Served by the static handler through the Lambda adapter.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Minimal SAM template: a Function URL in front of the adapter. An API Gateway
|
|
2
|
+
# HTTP API works the same way; a REST API does not, because payload format 1.0
|
|
3
|
+
# cannot preserve the original request encoding.
|
|
4
|
+
AWSTemplateFormatVersion: '2010-09-09'
|
|
5
|
+
Transform: AWS::Serverless-2016-10-31
|
|
6
|
+
|
|
7
|
+
Resources:
|
|
8
|
+
UrlcodeFunction:
|
|
9
|
+
Type: AWS::Serverless::Function
|
|
10
|
+
Properties:
|
|
11
|
+
Handler: handler.handler
|
|
12
|
+
Runtime: nodejs22.x
|
|
13
|
+
MemorySize: 512
|
|
14
|
+
Timeout: 10
|
|
15
|
+
FunctionUrlConfig:
|
|
16
|
+
AuthType: NONE
|
|
17
|
+
Environment:
|
|
18
|
+
Variables:
|
|
19
|
+
# The public origin, so generated URLs name the domain a visitor used.
|
|
20
|
+
URLCODE_PUBLIC_HOST: !Ref PublicHost
|
|
21
|
+
# Paste the output of `urlcode permissions` only if routes declare
|
|
22
|
+
# env or secret bindings.
|
|
23
|
+
# URLCODE_POLICY: '{"version":1,...}'
|
|
24
|
+
|
|
25
|
+
Parameters:
|
|
26
|
+
PublicHost:
|
|
27
|
+
Type: String
|
|
28
|
+
Default: ''
|
|
29
|
+
Description: Public hostname without scheme, e.g. links.example.
|
|
30
|
+
|
|
31
|
+
Outputs:
|
|
32
|
+
FunctionUrl:
|
|
33
|
+
Value: !GetAtt UrlcodeFunctionUrl.FunctionUrl
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"path": "/go", "status": 302, "expectHeaders": {"location": "https://example.com/target"}},
|
|
3
|
+
{"path": "/u/42", "status": 302, "expectHeaders": {"location": "https://example.com/users/42"}},
|
|
4
|
+
{"path": "/u/42", "method": "HEAD", "status": 302, "expectHeaders": {"location": "https://example.com/users/42"}, "expectBody": ""},
|
|
5
|
+
{"path": "/health", "status": 200, "expectBody": "{\"ok\":true}"},
|
|
6
|
+
{"path": "/", "status": 200, "expectHeaders": {"content-type": "text/html; charset=utf-8"}},
|
|
7
|
+
{"path": "/files/notes.txt", "status": 200, "expectHeaders": {"content-type": "text/plain; charset=utf-8"}},
|
|
8
|
+
{"path": "/nothing-here", "status": 404}
|
|
9
|
+
]
|