@void/edge 0.20.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present, Cloudflare, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # @void/edge
2
+
3
+ Shared edge-runtime primitives for Void deployments.
4
+
5
+ The hosted dispatch Worker and direct Cloudflare deployments both consume this
6
+ package for path matching, redirects, rewrites, response headers, and static
7
+ asset resolution. The package contains no hosted-platform identity or routing
8
+ state.
@@ -0,0 +1,253 @@
1
+ import { AssetConfig, AssetConfig as AssetConfig$1, HeaderRule, HeaderRule as HeaderRule$1, RedirectRule, RedirectRule as RedirectRule$1 } from "@void/deploy-core";
2
+ //#region src/headers.d.ts
3
+ type HeaderRulePolicy = Readonly<{
4
+ /**
5
+ * Protect unrelated tenants that share one registrable domain. Direct
6
+ * deployments own their hostname and may safely use cookie header rules.
7
+ */
8
+ cookieIsolation?: "shared-domain" | "origin";
9
+ }>;
10
+ /** A single member of a `Clear-Site-Data` list. */
11
+ type ClearSiteDataMember = Readonly<{
12
+ /** The member text, trimmed of surrounding whitespace; kept for re-serialization. */
13
+ raw: string;
14
+ /**
15
+ * The quoted-string content (original case) when the member is a well-formed
16
+ * quoted-string, else `null`. A browser ignores members that are not
17
+ * well-formed quoted-strings.
18
+ */
19
+ directive: string | null;
20
+ }>;
21
+ /**
22
+ * Parse a `Clear-Site-Data` header value into its list members. The value is a
23
+ * comma-separated list of quoted-strings (W3C Clear-Site-Data); a comma inside a
24
+ * quoted-string does not split a member. Empty members are dropped.
25
+ */
26
+ export declare function parseClearSiteData(value: string): Array<ClearSiteDataMember>;
27
+ /**
28
+ * Sanitize a `Clear-Site-Data` value so a tenant rule can't clear cookies across
29
+ * the shared *.void.app boundary, while still allowing the safe directives.
30
+ *
31
+ * Only `"cookies"` and `"*"` clear cookies for the registrable domain (and thus
32
+ * every tenant's subdomain). `"cache"`, `"storage"`, `"executionContexts"`, and
33
+ * any future quoted directive are origin-scoped — each subdomain is its own
34
+ * origin — so they are kept verbatim. Malformed members are dropped (a browser
35
+ * ignores them, and dropping forecloses a lenient parser honoring them).
36
+ *
37
+ * Returns the filtered value, or `null` when nothing safe remains: a `"*"`
38
+ * includes cookies and can't be expressed without them, and a value that is only
39
+ * `"cookies"` collapses to empty.
40
+ */
41
+ export declare function sanitizeClearSiteData(value: string): string | null;
42
+ /**
43
+ * Apply matching header rules to a response.
44
+ * All matching rules merge; last match wins for same header name.
45
+ * Returns the original response unchanged when no rules or no matches.
46
+ */
47
+ export declare function applyHeaderRules(response: Response, pathname: string, rules?: ReadonlyArray<HeaderRule$1>, policy?: HeaderRulePolicy): Response;
48
+ //#endregion
49
+ //#region src/pattern.d.ts
50
+ type CompiledPattern = Readonly<{
51
+ regex: RegExp;
52
+ paramNames: ReadonlyArray<string>;
53
+ }>;
54
+ export declare function compilePattern(source: string): CompiledPattern;
55
+ /**
56
+ * Match a pattern against a pathname. Returns captured params, or null.
57
+ *
58
+ * Returns null (rather than throwing) when the pattern itself is malformed —
59
+ * e.g. multiple `*` producing duplicate `splat` capture names. Framework and
60
+ * API ingest both reject those, but a hand-crafted manifest could still reach
61
+ * the edge, and a single bad rule shouldn't 500 unrelated traffic.
62
+ */
63
+ export declare function matchPatternCaptures(pattern: string, pathname: string): Record<string, string> | null;
64
+ /**
65
+ * Match a pattern against a pathname (boolean form). Header-rule callers only
66
+ * need a yes/no — they don't care about captures. Malformed patterns yield
67
+ * `false` rather than throwing (see `matchPatternCaptures`).
68
+ */
69
+ export declare function matchPattern(pattern: string, pathname: string): boolean;
70
+ /**
71
+ * Match a pattern against a pathname using LITERAL semantics — no named
72
+ * captures. Only `*` is special (matches any characters including `/`); every
73
+ * other character (including `:`) is treated as a literal.
74
+ *
75
+ * This preserves the pre-`feat/rewrites` semantics of header rules,
76
+ * `routing.revalidate` keys, and `routing.revalidateQueryAllowlist` keys —
77
+ * those callers never had `:name` capture syntax and existing tenant configs
78
+ * may include literal `:` (e.g. `/api/v1:health`, `:batchGet`-style URLs).
79
+ *
80
+ * The capture-aware `matchPattern` above continues to be used by routing
81
+ * rules (`redirects`/`rewrites`/`fallbacks`) where `:name` capture syntax was
82
+ * intentionally introduced (see design-doc 0064).
83
+ */
84
+ export declare function matchLiteralPattern(pattern: string, pathname: string): boolean;
85
+ /**
86
+ * Match a REVALIDATE ROUTE pattern against a pathname using SEGMENT-ANCHORED
87
+ * semantics. Like `matchLiteralPattern` except a WHOLE-SEGMENT `:name` (a `:`
88
+ * right after `/` or at the start of the pattern, whose name is any non-empty
89
+ * run of non-`/` characters — NOT just JS-identifier chars, so `:post-id`,
90
+ * `:post.id`, etc. all anchor — running up to the next `/` or end of string)
91
+ * matches exactly ONE non-empty path segment (`[^/]+`). A whole-segment NAMED
92
+ * CATCH-ALL `:name{.+}` (a named catch-all page `[...path]` → `/docs/:path{.+}`)
93
+ * matches ONE-OR-MORE characters that MAY cross `/` (`.+`, so `/docs/a` and
94
+ * `/docs/a/b` both match, but bare `/docs` / `/docs/` do NOT). Only the exact
95
+ * `{.+}` constraint widens — a REGEX-constrained param like `:path{[0-9]+}`
96
+ * keeps its single-segment `[^/]+` meaning. `*` still expands to a catch-all
97
+ * that crosses `/`. A mid-segment `:` (e.g. `/api/v1:health`,
98
+ * `/api/documents:batchGet`) stays a LITERAL — only whole-segment `:name` /
99
+ * `:name{.+}` anchors.
100
+ *
101
+ * This is for `routing.revalidate` keys ONLY. Those keys are machine-generated
102
+ * from page routes on the deploy write side (`[slug]` → `/blog/:slug`,
103
+ * `[...path]` → `/docs/:path{.+}`), so the `:name` here is a real route param
104
+ * and MUST bind the same way the framework's own client route matcher
105
+ * (`patternToRegex` in `pages/manifest.ts`) binds it — matching the write-side
106
+ * matcher in `packages/api/src/routes/deploy.ts`.
107
+ *
108
+ * It is intentionally NOT used for user-authored header rules
109
+ * (`applyHeaderRules`) or `revalidateQueryAllowlist` keys
110
+ * (`buildVariantOriginalUrl`), where `:name` must stay a literal — those keep
111
+ * using `matchLiteralPattern`. Sharing the segment behaviour there would make a
112
+ * user rule like `/:batchGet` over-match every single-segment path.
113
+ *
114
+ * NOTE: this matcher has its OWN regex cache — the same pattern string compiles
115
+ * to a DIFFERENT regex than `matchLiteralPattern` produces, so the caches must
116
+ * not be shared or they would cross-contaminate.
117
+ */
118
+ export declare function matchSegmentPattern(pattern: string, pathname: string): boolean;
119
+ /**
120
+ * Substitute `:name` and `:name*` tokens in a destination string with the
121
+ * matching named captures from `params`. `:splat` works for legacy `*` patterns
122
+ * because `compilePattern` captures anonymous `*` as `splat`.
123
+ */
124
+ export declare function substituteParams(destination: string, params: Record<string, string>): string;
125
+ //#endregion
126
+ //#region src/routing-rules.d.ts
127
+ type RoutingRuleResult = {
128
+ type: "redirect";
129
+ destination: string;
130
+ status: number;
131
+ } | {
132
+ type: "rewrite";
133
+ destination: string;
134
+ };
135
+ /**
136
+ * Check routing rules (redirects + rewrites) against a request pathname.
137
+ *
138
+ * **First match wins** — matches Netlify `_redirects` and Vercel `vercel.json`
139
+ * semantics: users put more-specific rules above more-general ones, and the
140
+ * first rule whose source matches takes effect. (Header rules in this same
141
+ * package use last-match-wins for compatibility with Netlify `_headers`,
142
+ * which is the opposite of `_redirects`. The asymmetry is intentional.)
143
+ *
144
+ * `requestHost` is the WHATWG `url.hostname` (lowercase, port-stripped).
145
+ * Rules with a `host` field are skipped when `rule.host !== requestHost`;
146
+ * path-only rules apply to every host bound to the project.
147
+ *
148
+ * Returns a discriminated result. The caller is responsible for building the
149
+ * actual `Response` for redirects (so it can merge the request's query string
150
+ * into the `Location` header).
151
+ */
152
+ export declare function applyRoutingRules(pathname: string, rules?: ReadonlyArray<RedirectRule$1>, requestHost?: string): RoutingRuleResult | null;
153
+ /**
154
+ * Merge a request's query string into a redirect destination.
155
+ *
156
+ * If the destination already contains a `?`, its parameters take precedence
157
+ * over the request's. Otherwise the request's query is appended verbatim.
158
+ *
159
+ * A `#fragment` on the destination is stripped before the merge and
160
+ * re-appended afterwards, so `/new#faq` + `?utm=x` becomes `/new?utm=x#faq`
161
+ * rather than `/new#faq?utm=x` (which browsers treat as fragment text).
162
+ */
163
+ export declare function mergeQueryIntoLocation(destination: string, requestSearch: string): string;
164
+ //#endregion
165
+ //#region src/runtime.d.ts
166
+ type EdgeAssetFetcher = Readonly<{
167
+ fetch(request: Request): Promise<Response>;
168
+ }>;
169
+ type StaticEdgeEnv = Readonly<{
170
+ ASSETS: EdgeAssetFetcher;
171
+ }>;
172
+ type StaticEdgeConfig = Readonly<{
173
+ assetConfig?: AssetConfig$1;
174
+ headerRules?: ReadonlyArray<HeaderRule$1>;
175
+ redirectRules?: ReadonlyArray<RedirectRule$1>;
176
+ fallbackRules?: ReadonlyArray<RedirectRule$1>;
177
+ }>;
178
+ type StaticEdgeHandler = Readonly<{
179
+ fetch(request: Request, env: StaticEdgeEnv): Promise<Response>;
180
+ }>;
181
+ /**
182
+ * Create the thin Worker used by direct static, SPA, and SSG deployments.
183
+ * Routing semantics intentionally match the hosted dispatch Worker: redirects
184
+ * and rewrites run before assets, fallback rewrites run only after an asset
185
+ * miss, and response-header rules apply to the effective pathname.
186
+ */
187
+ export declare function createStaticEdgeHandler(config: StaticEdgeConfig): StaticEdgeHandler;
188
+ //#endregion
189
+ //#region src/static.d.ts
190
+ type ResolvedAsset = Readonly<{
191
+ path: string;
192
+ hash: string;
193
+ redirect?: string;
194
+ notFound?: boolean;
195
+ spa?: boolean;
196
+ }>;
197
+ /**
198
+ * Percent-decode a request pathname for a manifest lookup, honoring ONLY the
199
+ * encodings a conformant URL is required to use. Returns the raw value
200
+ * unchanged on a malformed sequence (e.g. a lone `%`) or on any non-canonical /
201
+ * over-encoded spelling.
202
+ *
203
+ * Request pathnames arrive percent-encoded — WHATWG `URL.pathname` never
204
+ * decodes — but manifest keys are the asset's decoded on-disk path (a file
205
+ * named `oxc masthead.riv` is stored under `/assets/oxc masthead.riv`, not
206
+ * `%20`). Callers try the raw key first, then this decoded form.
207
+ *
208
+ * The decoded form is accepted only when re-encoding it reproduces the request
209
+ * path (up to percent-escape letter case) — i.e. the only encoded bytes were
210
+ * characters a URL MUST encode (space, `#`, non-ASCII, …). This rejects
211
+ * over-encoded spellings of ordinary path text (`%2F`→`/`, `%61`→`a`, `%5F`→`_`,
212
+ * `%2e%2e`→`..`), which a browser would never send and which would otherwise let
213
+ * a request reconstruct a guarded path (`/assets%2Fx`, `/%61ssets/x`,
214
+ * `/%5F%5Fvoid/x`) and serve the manifest entry while the raw-`url.pathname`
215
+ * policy gates — the `/__void/*` reserved-route block, the hashed-asset prefix
216
+ * gate, and the header/redirect rules — never fire. The URL pathname setter
217
+ * applies exactly the WHATWG "path percent-encode set", making it the canonical
218
+ * re-encoder.
219
+ *
220
+ * Known limitation: a file whose name contains a literal `%` (e.g. `logo%20.svg`,
221
+ * requested as `logo%2520.svg`) is not resolved — the path encoder leaves `%`
222
+ * as-is, so the round-trip can't reproduce the double-escaped request. Such
223
+ * names 404'd before this change too, so this is not a regression.
224
+ */
225
+ export declare function safeDecodePathname(pathname: string): string;
226
+ /**
227
+ * Resolve a request pathname to a manifest entry using AssetConfig rules.
228
+ * Replicates Cloudflare Workers Assets path resolution behavior.
229
+ */
230
+ export declare function resolveStaticPath(pathname: string, manifest: Record<string, string>, config: AssetConfig$1): ResolvedAsset | null;
231
+ /**
232
+ * Get the Content-Type header value for a given file path based on its extension.
233
+ */
234
+ export declare function getContentType(path: string): string;
235
+ /**
236
+ * Resolve the Content-Type to serve for a stored asset, reconciling the value
237
+ * persisted at upload time (R2 `httpMetadata.contentType` / KV `ct` metadata)
238
+ * with a fresh path-based lookup. Returns `undefined` when no Content-Type
239
+ * header should be emitted.
240
+ *
241
+ * `application/null` is a sentinel (matching Wrangler) meaning "omit the
242
+ * Content-Type header entirely".
243
+ *
244
+ * `application/octet-stream` is the uploader's no-information default: older
245
+ * CLI versions stamped it on any extension their MIME table did not recognize
246
+ * (e.g. `.md`). Because assets are content-addressed and never re-uploaded,
247
+ * those stale octet-stream values can't be corrected at the source, so we
248
+ * treat a stored octet-stream as "unknown" and re-derive from the path here.
249
+ * Any other stored value is authoritative and honored as-is.
250
+ */
251
+ export declare function resolveAssetContentType(stored: string | null | undefined, path: string): string | undefined;
252
+ //#endregion
253
+ export type { AssetConfig, ClearSiteDataMember, CompiledPattern, EdgeAssetFetcher, HeaderRule, HeaderRulePolicy, RedirectRule, ResolvedAsset, RoutingRuleResult, StaticEdgeConfig, StaticEdgeEnv, StaticEdgeHandler };
package/dist/index.mjs ADDED
@@ -0,0 +1,681 @@
1
+ //#region src/pattern.ts
2
+ /**
3
+ * Shared path-pattern compiler used by header rules and routing rules.
4
+ *
5
+ * Syntax:
6
+ * `:name` — single path segment, captured as `name`
7
+ * `:name*` — zero or more path segments, captured as `name`
8
+ * `*` — zero or more path segments, captured as `splat` (legacy)
9
+ *
10
+ * When a legacy pattern contains multiple anonymous `*`, the first captures
11
+ * as `splat` and later wildcards are non-capturing. Boolean matching remains
12
+ * backward compatible for existing header/revalidate patterns.
13
+ */
14
+ const MAX_CACHE = 256;
15
+ const compileCache = /* @__PURE__ */ new Map();
16
+ const REGEX_SPECIAL = /[.+?^${}()|[\]\\]/;
17
+ const PARAM_CHAR = /[A-Za-z0-9_]/;
18
+ function compilePattern(source) {
19
+ const cached = compileCache.get(source);
20
+ if (cached) {
21
+ compileCache.delete(source);
22
+ compileCache.set(source, cached);
23
+ return cached;
24
+ }
25
+ const paramNames = [];
26
+ let legacySplatCaptured = false;
27
+ let pattern = "";
28
+ let index = 0;
29
+ while (index < source.length) {
30
+ const character = source[index];
31
+ if (character === ":") {
32
+ let nameEnd = index + 1;
33
+ while (nameEnd < source.length && PARAM_CHAR.test(source[nameEnd])) nameEnd++;
34
+ const name = source.slice(index + 1, nameEnd);
35
+ if (!name) {
36
+ pattern += "\\:";
37
+ index++;
38
+ continue;
39
+ }
40
+ if (source[nameEnd] === "*") {
41
+ paramNames.push(name);
42
+ pattern += `(?<${name}>.*)`;
43
+ index = nameEnd + 1;
44
+ } else {
45
+ paramNames.push(name);
46
+ pattern += `(?<${name}>[^/]+)`;
47
+ index = nameEnd;
48
+ }
49
+ } else if (character === "*") {
50
+ if (legacySplatCaptured) pattern += `.*`;
51
+ else {
52
+ paramNames.push("splat");
53
+ pattern += `(?<splat>.*)`;
54
+ legacySplatCaptured = true;
55
+ }
56
+ index++;
57
+ } else {
58
+ if (REGEX_SPECIAL.test(character)) pattern += "\\";
59
+ pattern += character;
60
+ index++;
61
+ }
62
+ }
63
+ const compiled = {
64
+ regex: new RegExp(`^${pattern}$`),
65
+ paramNames
66
+ };
67
+ if (compileCache.size >= MAX_CACHE) {
68
+ const first = compileCache.keys().next();
69
+ if (!first.done) compileCache.delete(first.value);
70
+ }
71
+ compileCache.set(source, compiled);
72
+ return compiled;
73
+ }
74
+ /**
75
+ * Match a pattern against a pathname. Returns captured params, or null.
76
+ *
77
+ * Returns null (rather than throwing) when the pattern itself is malformed —
78
+ * e.g. multiple `*` producing duplicate `splat` capture names. Framework and
79
+ * API ingest both reject those, but a hand-crafted manifest could still reach
80
+ * the edge, and a single bad rule shouldn't 500 unrelated traffic.
81
+ */
82
+ function matchPatternCaptures(pattern, pathname) {
83
+ let compiled;
84
+ try {
85
+ compiled = compilePattern(pattern);
86
+ } catch {
87
+ return null;
88
+ }
89
+ const match = compiled.regex.exec(pathname);
90
+ if (!match) return null;
91
+ return match.groups ?? {};
92
+ }
93
+ /**
94
+ * Match a pattern against a pathname (boolean form). Header-rule callers only
95
+ * need a yes/no — they don't care about captures. Malformed patterns yield
96
+ * `false` rather than throwing (see `matchPatternCaptures`).
97
+ */
98
+ function matchPattern(pattern, pathname) {
99
+ try {
100
+ return compilePattern(pattern).regex.test(pathname);
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+ const literalRegexCache = /* @__PURE__ */ new Map();
106
+ /**
107
+ * Match a pattern against a pathname using LITERAL semantics — no named
108
+ * captures. Only `*` is special (matches any characters including `/`); every
109
+ * other character (including `:`) is treated as a literal.
110
+ *
111
+ * This preserves the pre-`feat/rewrites` semantics of header rules,
112
+ * `routing.revalidate` keys, and `routing.revalidateQueryAllowlist` keys —
113
+ * those callers never had `:name` capture syntax and existing tenant configs
114
+ * may include literal `:` (e.g. `/api/v1:health`, `:batchGet`-style URLs).
115
+ *
116
+ * The capture-aware `matchPattern` above continues to be used by routing
117
+ * rules (`redirects`/`rewrites`/`fallbacks`) where `:name` capture syntax was
118
+ * intentionally introduced (see design-doc 0064).
119
+ */
120
+ function matchLiteralPattern(pattern, pathname) {
121
+ let regex = literalRegexCache.get(pattern);
122
+ if (regex) {
123
+ literalRegexCache.delete(pattern);
124
+ literalRegexCache.set(pattern, regex);
125
+ } else {
126
+ try {
127
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
128
+ regex = new RegExp(`^${escaped}$`);
129
+ } catch {
130
+ return false;
131
+ }
132
+ if (literalRegexCache.size >= MAX_CACHE) {
133
+ const first = literalRegexCache.keys().next();
134
+ if (!first.done) literalRegexCache.delete(first.value);
135
+ }
136
+ literalRegexCache.set(pattern, regex);
137
+ }
138
+ try {
139
+ return regex.test(pathname);
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+ const segmentRegexCache = /* @__PURE__ */ new Map();
145
+ /**
146
+ * Match a REVALIDATE ROUTE pattern against a pathname using SEGMENT-ANCHORED
147
+ * semantics. Like `matchLiteralPattern` except a WHOLE-SEGMENT `:name` (a `:`
148
+ * right after `/` or at the start of the pattern, whose name is any non-empty
149
+ * run of non-`/` characters — NOT just JS-identifier chars, so `:post-id`,
150
+ * `:post.id`, etc. all anchor — running up to the next `/` or end of string)
151
+ * matches exactly ONE non-empty path segment (`[^/]+`). A whole-segment NAMED
152
+ * CATCH-ALL `:name{.+}` (a named catch-all page `[...path]` → `/docs/:path{.+}`)
153
+ * matches ONE-OR-MORE characters that MAY cross `/` (`.+`, so `/docs/a` and
154
+ * `/docs/a/b` both match, but bare `/docs` / `/docs/` do NOT). Only the exact
155
+ * `{.+}` constraint widens — a REGEX-constrained param like `:path{[0-9]+}`
156
+ * keeps its single-segment `[^/]+` meaning. `*` still expands to a catch-all
157
+ * that crosses `/`. A mid-segment `:` (e.g. `/api/v1:health`,
158
+ * `/api/documents:batchGet`) stays a LITERAL — only whole-segment `:name` /
159
+ * `:name{.+}` anchors.
160
+ *
161
+ * This is for `routing.revalidate` keys ONLY. Those keys are machine-generated
162
+ * from page routes on the deploy write side (`[slug]` → `/blog/:slug`,
163
+ * `[...path]` → `/docs/:path{.+}`), so the `:name` here is a real route param
164
+ * and MUST bind the same way the framework's own client route matcher
165
+ * (`patternToRegex` in `pages/manifest.ts`) binds it — matching the write-side
166
+ * matcher in `packages/api/src/routes/deploy.ts`.
167
+ *
168
+ * It is intentionally NOT used for user-authored header rules
169
+ * (`applyHeaderRules`) or `revalidateQueryAllowlist` keys
170
+ * (`buildVariantOriginalUrl`), where `:name` must stay a literal — those keep
171
+ * using `matchLiteralPattern`. Sharing the segment behaviour there would make a
172
+ * user rule like `/:batchGet` over-match every single-segment path.
173
+ *
174
+ * NOTE: this matcher has its OWN regex cache — the same pattern string compiles
175
+ * to a DIFFERENT regex than `matchLiteralPattern` produces, so the caches must
176
+ * not be shared or they would cross-contaminate.
177
+ */
178
+ function matchSegmentPattern(pattern, pathname) {
179
+ let regex = segmentRegexCache.get(pattern);
180
+ if (regex) {
181
+ segmentRegexCache.delete(pattern);
182
+ segmentRegexCache.set(pattern, regex);
183
+ } else {
184
+ try {
185
+ const escaped = pattern.replace(/(^|\/):[^/{}]+\{\.\+\}(?=\/|$)/g, "$1").replace(/(^|\/):[^/]+(?=\/|$)/g, "$1").replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*").replaceAll("", "[^/]+").replaceAll("", ".+");
186
+ regex = new RegExp(`^${escaped}$`);
187
+ } catch {
188
+ return false;
189
+ }
190
+ if (segmentRegexCache.size >= MAX_CACHE) {
191
+ const first = segmentRegexCache.keys().next();
192
+ if (!first.done) segmentRegexCache.delete(first.value);
193
+ }
194
+ segmentRegexCache.set(pattern, regex);
195
+ }
196
+ try {
197
+ return regex.test(pathname);
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+ /**
203
+ * Substitute `:name` and `:name*` tokens in a destination string with the
204
+ * matching named captures from `params`. `:splat` works for legacy `*` patterns
205
+ * because `compilePattern` captures anonymous `*` as `splat`.
206
+ */
207
+ function substituteParams(destination, params) {
208
+ return destination.replace(/:([A-Za-z0-9_]+)\*?/g, (whole, name) => {
209
+ return params[name] ?? whole;
210
+ });
211
+ }
212
+ //#endregion
213
+ //#region src/headers.ts
214
+ /**
215
+ * Headers that user header rules may not set at all, because they are always a
216
+ * cookie operation. On the shared *.void.app domain (not on the PSL) void.app
217
+ * is the registrable domain, so a `Set-Cookie` with `Domain=.void.app` is
218
+ * readable by every tenant's subdomain. Dropped wholesale.
219
+ *
220
+ * `Clear-Site-Data` is the other cookie trust-boundary header, but it is NOT a
221
+ * blanket block — see {@link sanitizeClearSiteData}: only its cookie-clearing
222
+ * directives are stripped, since its other directives are origin-scoped.
223
+ *
224
+ * HSTS and other per-host headers are safe — they don't cross subdomain
225
+ * boundaries (includeSubDomains scopes to the responding host's subtree).
226
+ *
227
+ * TODO: Once void.app is registered on the Public Suffix List, these defenses
228
+ * can be removed — browsers will scope cross-subdomain cookies automatically.
229
+ */
230
+ const BLOCKED_HEADERS = /* @__PURE__ */ new Set(["set-cookie"]);
231
+ /**
232
+ * Parse one list member into its quoted-string content, or `null` when it is not
233
+ * a well-formed quoted-string. Implements RFC 7230's
234
+ * `quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE`: it must be wrapped
235
+ * in double quotes, a backslash escapes the next character, and an unescaped
236
+ * inner quote (or a dangling escape) is malformed.
237
+ */
238
+ function parseQuotedString(member) {
239
+ if (member.length < 2 || member[0] !== "\"" || member[member.length - 1] !== "\"") return null;
240
+ let content = "";
241
+ for (let i = 1; i < member.length - 1; i += 1) {
242
+ const char = member[i];
243
+ if (char === "\\") {
244
+ i += 1;
245
+ if (i >= member.length - 1) return null;
246
+ content += member[i];
247
+ } else if (char === "\"") return null;
248
+ else content += char;
249
+ }
250
+ return content;
251
+ }
252
+ /**
253
+ * Parse a `Clear-Site-Data` header value into its list members. The value is a
254
+ * comma-separated list of quoted-strings (W3C Clear-Site-Data); a comma inside a
255
+ * quoted-string does not split a member. Empty members are dropped.
256
+ */
257
+ function parseClearSiteData(value) {
258
+ const members = [];
259
+ const pushMember = (slice) => {
260
+ const raw = slice.trim();
261
+ if (raw.length > 0) members.push({
262
+ raw,
263
+ directive: parseQuotedString(raw)
264
+ });
265
+ };
266
+ let start = 0;
267
+ let inQuotes = false;
268
+ for (let i = 0; i < value.length; i += 1) {
269
+ const char = value[i];
270
+ if (inQuotes) {
271
+ if (char === "\\") i += 1;
272
+ else if (char === "\"") inQuotes = false;
273
+ } else if (char === "\"") inQuotes = true;
274
+ else if (char === ",") {
275
+ pushMember(value.slice(start, i));
276
+ start = i + 1;
277
+ }
278
+ }
279
+ pushMember(value.slice(start));
280
+ return members;
281
+ }
282
+ /**
283
+ * Sanitize a `Clear-Site-Data` value so a tenant rule can't clear cookies across
284
+ * the shared *.void.app boundary, while still allowing the safe directives.
285
+ *
286
+ * Only `"cookies"` and `"*"` clear cookies for the registrable domain (and thus
287
+ * every tenant's subdomain). `"cache"`, `"storage"`, `"executionContexts"`, and
288
+ * any future quoted directive are origin-scoped — each subdomain is its own
289
+ * origin — so they are kept verbatim. Malformed members are dropped (a browser
290
+ * ignores them, and dropping forecloses a lenient parser honoring them).
291
+ *
292
+ * Returns the filtered value, or `null` when nothing safe remains: a `"*"`
293
+ * includes cookies and can't be expressed without them, and a value that is only
294
+ * `"cookies"` collapses to empty.
295
+ */
296
+ function sanitizeClearSiteData(value) {
297
+ const kept = [];
298
+ for (const { raw, directive } of parseClearSiteData(value)) {
299
+ const normalized = directive?.toLowerCase();
300
+ if (normalized === "*") return null;
301
+ if (directive === null || normalized === "cookies") continue;
302
+ kept.push(raw);
303
+ }
304
+ return kept.length > 0 ? kept.join(", ") : null;
305
+ }
306
+ /**
307
+ * Apply matching header rules to a response.
308
+ * All matching rules merge; last match wins for same header name.
309
+ * Returns the original response unchanged when no rules or no matches.
310
+ */
311
+ function applyHeaderRules(response, pathname, rules, policy) {
312
+ if (!rules || rules.length === 0) return response;
313
+ const matched = [];
314
+ for (const rule of rules) if (matchLiteralPattern(rule.pattern, pathname)) matched.push(...rule.headers);
315
+ if (matched.length === 0) return response;
316
+ const newResponse = new Response(response.body, response);
317
+ const isolateSharedDomainCookies = policy?.cookieIsolation !== "origin";
318
+ const rawOriginClearSiteData = isolateSharedDomainCookies ? response.headers.get("clear-site-data") : null;
319
+ const originClearSiteData = rawOriginClearSiteData === null ? null : sanitizeClearSiteData(rawOriginClearSiteData);
320
+ for (const [name, value] of matched) {
321
+ const lowerName = name.toLowerCase();
322
+ if (isolateSharedDomainCookies && BLOCKED_HEADERS.has(lowerName)) continue;
323
+ if (isolateSharedDomainCookies && lowerName === "clear-site-data") {
324
+ const safe = sanitizeClearSiteData(value);
325
+ if (safe !== null) newResponse.headers.set(name, safe);
326
+ else if (originClearSiteData !== null) newResponse.headers.set(name, originClearSiteData);
327
+ else newResponse.headers.delete(name);
328
+ continue;
329
+ }
330
+ newResponse.headers.set(name, value);
331
+ }
332
+ return newResponse;
333
+ }
334
+ //#endregion
335
+ //#region src/routing-rules.ts
336
+ /**
337
+ * Check routing rules (redirects + rewrites) against a request pathname.
338
+ *
339
+ * **First match wins** — matches Netlify `_redirects` and Vercel `vercel.json`
340
+ * semantics: users put more-specific rules above more-general ones, and the
341
+ * first rule whose source matches takes effect. (Header rules in this same
342
+ * package use last-match-wins for compatibility with Netlify `_headers`,
343
+ * which is the opposite of `_redirects`. The asymmetry is intentional.)
344
+ *
345
+ * `requestHost` is the WHATWG `url.hostname` (lowercase, port-stripped).
346
+ * Rules with a `host` field are skipped when `rule.host !== requestHost`;
347
+ * path-only rules apply to every host bound to the project.
348
+ *
349
+ * Returns a discriminated result. The caller is responsible for building the
350
+ * actual `Response` for redirects (so it can merge the request's query string
351
+ * into the `Location` header).
352
+ */
353
+ function applyRoutingRules(pathname, rules, requestHost) {
354
+ if (!rules?.length) return null;
355
+ for (const rule of rules) {
356
+ if (rule.host && rule.host !== requestHost) continue;
357
+ let regex;
358
+ try {
359
+ ({regex} = compilePattern(rule.source));
360
+ } catch (err) {
361
+ console.warn(`[dispatch] skipping routing rule with invalid source ${JSON.stringify(rule.source)}: ${err instanceof Error ? err.message : String(err)}`);
362
+ continue;
363
+ }
364
+ const match = regex.exec(pathname);
365
+ if (!match) continue;
366
+ const destination = substituteParams(rule.destination, match.groups ?? {});
367
+ if (rule.status === 200) return {
368
+ type: "rewrite",
369
+ destination
370
+ };
371
+ return {
372
+ type: "redirect",
373
+ destination,
374
+ status: rule.status
375
+ };
376
+ }
377
+ return null;
378
+ }
379
+ /**
380
+ * Merge a request's query string into a redirect destination.
381
+ *
382
+ * If the destination already contains a `?`, its parameters take precedence
383
+ * over the request's. Otherwise the request's query is appended verbatim.
384
+ *
385
+ * A `#fragment` on the destination is stripped before the merge and
386
+ * re-appended afterwards, so `/new#faq` + `?utm=x` becomes `/new?utm=x#faq`
387
+ * rather than `/new#faq?utm=x` (which browsers treat as fragment text).
388
+ */
389
+ function mergeQueryIntoLocation(destination, requestSearch) {
390
+ if (!requestSearch || requestSearch === "?") return destination;
391
+ const fragIdx = destination.indexOf("#");
392
+ const base = fragIdx === -1 ? destination : destination.slice(0, fragIdx);
393
+ const fragment = fragIdx === -1 ? "" : destination.slice(fragIdx);
394
+ const destQueryIdx = base.indexOf("?");
395
+ if (destQueryIdx === -1) return base + requestSearch + fragment;
396
+ if (destQueryIdx === base.length - 1) return base.slice(0, destQueryIdx) + fragment;
397
+ const reqParams = new URLSearchParams(requestSearch);
398
+ const destParams = new URLSearchParams(base.slice(destQueryIdx + 1));
399
+ for (const [key, value] of reqParams) if (!destParams.has(key)) destParams.append(key, value);
400
+ return base.slice(0, destQueryIdx) + "?" + destParams.toString() + fragment;
401
+ }
402
+ //#endregion
403
+ //#region src/runtime.ts
404
+ /**
405
+ * Create the thin Worker used by direct static, SPA, and SSG deployments.
406
+ * Routing semantics intentionally match the hosted dispatch Worker: redirects
407
+ * and rewrites run before assets, fallback rewrites run only after an asset
408
+ * miss, and response-header rules apply to the effective pathname.
409
+ */
410
+ function createStaticEdgeHandler(config) {
411
+ return { async fetch(request, env) {
412
+ const originalUrl = new URL(request.url);
413
+ if (request.method === "GET" && originalUrl.pathname === "/__void/ready") return Response.json({ ready: true });
414
+ let effectiveUrl = new URL(originalUrl);
415
+ let effectiveRequest = request;
416
+ const route = applyRoutingRules(effectiveUrl.pathname, config.redirectRules, effectiveUrl.hostname);
417
+ if (route?.type === "redirect") return applyHeaderRules(new Response(null, {
418
+ status: route.status,
419
+ headers: { location: mergeQueryIntoLocation(route.destination, effectiveUrl.search) }
420
+ }), effectiveUrl.pathname, config.headerRules, { cookieIsolation: "origin" });
421
+ if (route?.type === "rewrite") {
422
+ effectiveUrl = rewriteUrl(effectiveUrl, route.destination);
423
+ effectiveRequest = rewriteRequest(request, effectiveUrl, originalUrl.toString());
424
+ }
425
+ let response = await env.ASSETS.fetch(effectiveRequest);
426
+ if (response.status === 404) {
427
+ const fallback = applyRoutingRules(effectiveUrl.pathname, config.fallbackRules, effectiveUrl.hostname);
428
+ if (fallback?.type === "rewrite") {
429
+ effectiveUrl = rewriteUrl(effectiveUrl, fallback.destination);
430
+ effectiveRequest = rewriteRequest(request, effectiveUrl, originalUrl.toString());
431
+ response = await env.ASSETS.fetch(effectiveRequest);
432
+ }
433
+ }
434
+ return applyHeaderRules(response, effectiveUrl.pathname, config.headerRules, { cookieIsolation: "origin" });
435
+ } };
436
+ }
437
+ function rewriteUrl(url, destination) {
438
+ const rewritten = new URL(url);
439
+ const destinationUrl = new URL(destination, url);
440
+ rewritten.pathname = destinationUrl.pathname;
441
+ rewritten.search = destinationUrl.search || url.search;
442
+ return rewritten;
443
+ }
444
+ function rewriteRequest(request, url, originalUrl) {
445
+ const headers = new Headers(request.headers);
446
+ headers.set("X-Void-Original-URL", originalUrl);
447
+ const rewritten = new Request(url.toString(), request);
448
+ return new Request(rewritten, { headers });
449
+ }
450
+ //#endregion
451
+ //#region src/static.ts
452
+ /**
453
+ * Percent-decode a request pathname for a manifest lookup, honoring ONLY the
454
+ * encodings a conformant URL is required to use. Returns the raw value
455
+ * unchanged on a malformed sequence (e.g. a lone `%`) or on any non-canonical /
456
+ * over-encoded spelling.
457
+ *
458
+ * Request pathnames arrive percent-encoded — WHATWG `URL.pathname` never
459
+ * decodes — but manifest keys are the asset's decoded on-disk path (a file
460
+ * named `oxc masthead.riv` is stored under `/assets/oxc masthead.riv`, not
461
+ * `%20`). Callers try the raw key first, then this decoded form.
462
+ *
463
+ * The decoded form is accepted only when re-encoding it reproduces the request
464
+ * path (up to percent-escape letter case) — i.e. the only encoded bytes were
465
+ * characters a URL MUST encode (space, `#`, non-ASCII, …). This rejects
466
+ * over-encoded spellings of ordinary path text (`%2F`→`/`, `%61`→`a`, `%5F`→`_`,
467
+ * `%2e%2e`→`..`), which a browser would never send and which would otherwise let
468
+ * a request reconstruct a guarded path (`/assets%2Fx`, `/%61ssets/x`,
469
+ * `/%5F%5Fvoid/x`) and serve the manifest entry while the raw-`url.pathname`
470
+ * policy gates — the `/__void/*` reserved-route block, the hashed-asset prefix
471
+ * gate, and the header/redirect rules — never fire. The URL pathname setter
472
+ * applies exactly the WHATWG "path percent-encode set", making it the canonical
473
+ * re-encoder.
474
+ *
475
+ * Known limitation: a file whose name contains a literal `%` (e.g. `logo%20.svg`,
476
+ * requested as `logo%2520.svg`) is not resolved — the path encoder leaves `%`
477
+ * as-is, so the round-trip can't reproduce the double-escaped request. Such
478
+ * names 404'd before this change too, so this is not a regression.
479
+ */
480
+ function safeDecodePathname(pathname) {
481
+ let decoded;
482
+ try {
483
+ decoded = decodeURIComponent(pathname);
484
+ } catch {
485
+ return pathname;
486
+ }
487
+ if (decoded === pathname) return pathname;
488
+ const canonical = reencodePathname(decoded);
489
+ if (canonical === null) return pathname;
490
+ return normalizePercentCase(canonical) === normalizePercentCase(pathname) ? decoded : pathname;
491
+ }
492
+ /** Uppercase the hex digits of every `%xx` escape so encodings compare equal
493
+ * regardless of the letter case a client used. */
494
+ function normalizePercentCase(pathname) {
495
+ return pathname.replace(/%[0-9a-fA-F]{2}/g, (escape) => escape.toUpperCase());
496
+ }
497
+ /**
498
+ * Serialize a decoded path back to a safe URL pathname via the WHATWG "path
499
+ * percent-encode set" (the `URL.pathname` setter). Returns null if the value
500
+ * can't be encoded. Used to canonicalize the decoded lookup key and to make a
501
+ * redirect Location URL-safe.
502
+ */
503
+ function reencodePathname(pathname) {
504
+ try {
505
+ const encoder = new URL("https://void.invalid/");
506
+ encoder.pathname = pathname;
507
+ return encoder.pathname;
508
+ } catch {
509
+ return null;
510
+ }
511
+ }
512
+ /**
513
+ * Match a pathname against the manifest via exact match + html_handling rules
514
+ * (no not-found fallback). Returns null when nothing matches.
515
+ */
516
+ function matchManifestEntry(pathname, manifest, htmlHandling) {
517
+ if (manifest[pathname]) return {
518
+ path: pathname,
519
+ hash: manifest[pathname]
520
+ };
521
+ switch (htmlHandling) {
522
+ case "auto-trailing-slash": {
523
+ const withHtml = pathname + ".html";
524
+ if (manifest[withHtml]) return {
525
+ path: withHtml,
526
+ hash: manifest[withHtml]
527
+ };
528
+ const withIndex = (pathname.endsWith("/") ? pathname : pathname + "/") + "index.html";
529
+ if (manifest[withIndex]) return {
530
+ path: withIndex,
531
+ hash: manifest[withIndex]
532
+ };
533
+ break;
534
+ }
535
+ case "force-trailing-slash": {
536
+ if (!pathname.endsWith("/") && pathname !== "/") {
537
+ const withIndex = pathname + "/index.html";
538
+ if (manifest[withIndex]) return {
539
+ path: withIndex,
540
+ hash: manifest[withIndex],
541
+ redirect: pathname + "/"
542
+ };
543
+ }
544
+ const indexPath = pathname + "index.html";
545
+ if (manifest[indexPath]) return {
546
+ path: indexPath,
547
+ hash: manifest[indexPath]
548
+ };
549
+ break;
550
+ }
551
+ case "drop-trailing-slash": {
552
+ if (pathname === "/") {
553
+ if (manifest["/index.html"]) return {
554
+ path: "/index.html",
555
+ hash: manifest["/index.html"]
556
+ };
557
+ break;
558
+ }
559
+ if (pathname.endsWith("/")) {
560
+ const withoutSlash = pathname.slice(0, -1);
561
+ const withHtml = withoutSlash + ".html";
562
+ if (manifest[withHtml]) return {
563
+ path: withHtml,
564
+ hash: manifest[withHtml],
565
+ redirect: withoutSlash
566
+ };
567
+ }
568
+ const withHtml = pathname + ".html";
569
+ if (manifest[withHtml]) return {
570
+ path: withHtml,
571
+ hash: manifest[withHtml]
572
+ };
573
+ break;
574
+ }
575
+ }
576
+ return null;
577
+ }
578
+ /**
579
+ * Resolve a request pathname to a manifest entry using AssetConfig rules.
580
+ * Replicates Cloudflare Workers Assets path resolution behavior.
581
+ */
582
+ function resolveStaticPath(pathname, manifest, config) {
583
+ const htmlHandling = config.html_handling ?? "auto-trailing-slash";
584
+ let matched = matchManifestEntry(pathname, manifest, htmlHandling);
585
+ if (!matched) {
586
+ const decoded = safeDecodePathname(pathname);
587
+ if (decoded !== pathname) {
588
+ matched = matchManifestEntry(decoded, manifest, htmlHandling);
589
+ if (matched?.redirect !== void 0) {
590
+ const safeRedirect = reencodePathname(matched.redirect);
591
+ if (safeRedirect !== null) matched = {
592
+ ...matched,
593
+ redirect: safeRedirect
594
+ };
595
+ }
596
+ }
597
+ }
598
+ if (matched) return matched;
599
+ switch (config.not_found_handling ?? "none") {
600
+ case "single-page-application":
601
+ if (manifest["/index.html"]) return {
602
+ path: "/index.html",
603
+ hash: manifest["/index.html"],
604
+ spa: true
605
+ };
606
+ break;
607
+ case "404-page": if (manifest["/404.html"]) return {
608
+ path: "/404.html",
609
+ hash: manifest["/404.html"],
610
+ notFound: true
611
+ };
612
+ }
613
+ return null;
614
+ }
615
+ const MIME_TYPES = {
616
+ html: "text/html; charset=utf-8",
617
+ htm: "text/html; charset=utf-8",
618
+ css: "text/css; charset=utf-8",
619
+ js: "text/javascript; charset=utf-8",
620
+ mjs: "text/javascript; charset=utf-8",
621
+ json: "application/json; charset=utf-8",
622
+ xml: "application/xml; charset=utf-8",
623
+ svg: "image/svg+xml; charset=utf-8",
624
+ txt: "text/plain; charset=utf-8",
625
+ csv: "text/csv; charset=utf-8",
626
+ md: "text/markdown; charset=utf-8",
627
+ markdown: "text/markdown; charset=utf-8",
628
+ yaml: "text/yaml; charset=utf-8",
629
+ yml: "text/yaml; charset=utf-8",
630
+ toml: "application/toml",
631
+ png: "image/png",
632
+ jpg: "image/jpeg",
633
+ jpeg: "image/jpeg",
634
+ gif: "image/gif",
635
+ ico: "image/x-icon",
636
+ webp: "image/webp",
637
+ avif: "image/avif",
638
+ woff: "font/woff",
639
+ woff2: "font/woff2",
640
+ ttf: "font/ttf",
641
+ otf: "font/otf",
642
+ eot: "application/vnd.ms-fontobject",
643
+ pdf: "application/pdf",
644
+ wasm: "application/wasm",
645
+ webmanifest: "application/manifest+json; charset=utf-8",
646
+ map: "application/json; charset=utf-8",
647
+ mp4: "video/mp4",
648
+ webm: "video/webm",
649
+ mp3: "audio/mpeg",
650
+ ogg: "audio/ogg"
651
+ };
652
+ /**
653
+ * Get the Content-Type header value for a given file path based on its extension.
654
+ */
655
+ function getContentType(path) {
656
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
657
+ return MIME_TYPES[ext] ?? "application/octet-stream";
658
+ }
659
+ /**
660
+ * Resolve the Content-Type to serve for a stored asset, reconciling the value
661
+ * persisted at upload time (R2 `httpMetadata.contentType` / KV `ct` metadata)
662
+ * with a fresh path-based lookup. Returns `undefined` when no Content-Type
663
+ * header should be emitted.
664
+ *
665
+ * `application/null` is a sentinel (matching Wrangler) meaning "omit the
666
+ * Content-Type header entirely".
667
+ *
668
+ * `application/octet-stream` is the uploader's no-information default: older
669
+ * CLI versions stamped it on any extension their MIME table did not recognize
670
+ * (e.g. `.md`). Because assets are content-addressed and never re-uploaded,
671
+ * those stale octet-stream values can't be corrected at the source, so we
672
+ * treat a stored octet-stream as "unknown" and re-derive from the path here.
673
+ * Any other stored value is authoritative and honored as-is.
674
+ */
675
+ function resolveAssetContentType(stored, path) {
676
+ if (stored === "application/null") return;
677
+ if (!stored || stored === "application/octet-stream") return getContentType(path);
678
+ return stored;
679
+ }
680
+ //#endregion
681
+ export { applyHeaderRules, applyRoutingRules, compilePattern, createStaticEdgeHandler, getContentType, matchLiteralPattern, matchPattern, matchPatternCaptures, matchSegmentPattern, mergeQueryIntoLocation, parseClearSiteData, resolveAssetContentType, resolveStaticPath, safeDecodePathname, sanitizeClearSiteData, substituteParams };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@void/edge",
3
+ "version": "0.20.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/voidzero-dev/void.git",
7
+ "directory": "packages/edge"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.mts",
16
+ "import": "./dist/index.mjs"
17
+ }
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {
23
+ "@void/deploy-core": "0.20.0"
24
+ },
25
+ "devDependencies": {
26
+ "@cloudflare/workers-types": "^4.20260702.1",
27
+ "typescript": "7.0.2",
28
+ "vite-plus": "0.3.1"
29
+ },
30
+ "scripts": {
31
+ "build": "vp pack",
32
+ "dev": "vp pack --watch",
33
+ "typecheck": "tsc --noEmit"
34
+ }
35
+ }