@timber-js/app 0.2.0-alpha.155 → 0.2.0-alpha.156
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/dist/_chunks/{build-output-helper-B20ZuTSA.js → build-output-helper-Rp9r7gSA.js} +62 -9
- package/dist/_chunks/build-output-helper-Rp9r7gSA.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts +1 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +35 -5
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/adapters/shared.d.ts +1 -11
- package/dist/adapters/shared.d.ts.map +1 -1
- package/dist/fonts/google.d.ts.map +1 -1
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/server/asset-headers.d.ts +7 -3
- package/dist/server/asset-headers.d.ts.map +1 -1
- package/dist/server/internal.js +12 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/docs/more/50-ai-agent-instructions.mdx +169 -0
- package/package.json +3 -3
- package/src/adapters/build-output-helper.ts +52 -2
- package/src/adapters/cloudflare.ts +34 -4
- package/src/adapters/shared.ts +34 -6
- package/src/fonts/google.ts +56 -5
- package/src/plugins/fonts.ts +6 -0
- package/src/server/asset-headers.ts +7 -3
- package/src/server/pipeline-phases.ts +10 -2
- package/dist/_chunks/build-output-helper-B20ZuTSA.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC,CAmBvB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC,CAmBvB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAAC,QAAQ,CAAC,CAgTnB"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'AI Agent Instructions'
|
|
3
|
+
description: 'Structured reference for AI coding agents working with timber.js — key differences from Next.js, import paths, common mistakes, and reading order.'
|
|
4
|
+
slug: 'ai-agent-instructions'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# AI Agent Instructions
|
|
8
|
+
|
|
9
|
+
This page is for AI coding agents (Claude Code, Cursor, Copilot, Codex, etc.) and developers who paste context into agent prompts. It is concise and structured for machine consumption — but useful as a quick reference for humans too.
|
|
10
|
+
|
|
11
|
+
## Fetching These Docs as Markdown
|
|
12
|
+
|
|
13
|
+
Any timber.js documentation page can be fetched as plain markdown by sending an `Accept: text/markdown` header:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
curl -H 'Accept: text/markdown' https://timberjs.com/docs/ai-agent-instructions
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
This returns the page content as `text/markdown` with a `203` status code, suitable for piping into agent context or saving to a file.
|
|
20
|
+
|
|
21
|
+
## How timber.js Docs Are Organized
|
|
22
|
+
|
|
23
|
+
| Location | Purpose | Audience |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `packages/website/content/docs/` | User-facing guides, tutorials, API reference | Developers building with timber |
|
|
26
|
+
| `design/` | Design decisions, architecture, internal specs | Contributors and agents implementing features |
|
|
27
|
+
| `CLAUDE.md` | Contributor workflow, commands, code style | Agents working on timber itself |
|
|
28
|
+
|
|
29
|
+
**Read the user-facing docs for building apps.** Read the design docs only when you need to understand _why_ something works the way it does or when contributing to timber itself.
|
|
30
|
+
|
|
31
|
+
## Reading Order by Task
|
|
32
|
+
|
|
33
|
+
**Starting a new project:**
|
|
34
|
+
1. [Introduction](/docs/introduction) — what timber is and its philosophy
|
|
35
|
+
2. [Quick Start](/docs/quick-start) — project setup
|
|
36
|
+
3. [Pages and Layouts](/docs/pages-and-layouts) — routing basics
|
|
37
|
+
4. [Configuration](/docs/configuration) — `timber.config.ts`
|
|
38
|
+
|
|
39
|
+
**Adding a feature:**
|
|
40
|
+
1. [API: @timber-js/app/server](/docs/api-server) — server exports
|
|
41
|
+
2. [API: @timber-js/app/client](/docs/api-client) — client exports
|
|
42
|
+
3. The relevant guide (caching, forms, middleware, etc.)
|
|
43
|
+
|
|
44
|
+
**Debugging:**
|
|
45
|
+
1. [Coming from Next.js](/docs/coming-from-nextjs) — if the code assumes Next.js patterns
|
|
46
|
+
2. [The Flush Point](/docs/the-flush-point) — if status codes or headers are wrong
|
|
47
|
+
3. [Error Handling](/docs/error-handling) — deny, error boundaries, status files
|
|
48
|
+
|
|
49
|
+
## Key Differences from Next.js
|
|
50
|
+
|
|
51
|
+
These are the things you will get wrong if you assume Next.js behavior.
|
|
52
|
+
|
|
53
|
+
| Concept | Next.js | timber.js |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| Default component type | Client components need `'use client'` | **All components are server components** by default. Only add `'use client'` when you need browser APIs or hooks. |
|
|
56
|
+
| Fetch caching | `fetch()` is patched with implicit caching | `fetch()` is **never patched**. Use `cache()` from `@timber-js/app/cache` explicitly. |
|
|
57
|
+
| ISR | `revalidate` option | **Does not exist.** Use `cache()` with TTL and tags. |
|
|
58
|
+
| `loading.tsx` | Convention for auto-Suspense | **Does not exist.** Use `<Suspense>` explicitly. |
|
|
59
|
+
| Middleware | Single global `middleware.ts` with matchers | Per-segment `middleware.ts` files + global `proxy.ts`. One-arg signature: `middleware(ctx)`. |
|
|
60
|
+
| `notFound()` | `notFound()` from `next/navigation` | `deny(404)` from `@timber-js/app/server`. Sends a real HTTP 404. |
|
|
61
|
+
| `redirect()` | `redirect()` from `next/navigation` | `redirect()` from `@timber-js/app/server`. Same concept, different import. |
|
|
62
|
+
| Search params | Untyped `useSearchParams()` | Typed `defineSearchParams()` with Zod schemas in `params.ts`. |
|
|
63
|
+
| Auth pattern | In middleware or components | `access.ts` per segment with slot degradation. |
|
|
64
|
+
| Forms | `useFormState` / `useFormStatus` | `createActionClient` + `useActionState` returns a 4-tuple. Forms work without JS. |
|
|
65
|
+
|
|
66
|
+
## Import Map
|
|
67
|
+
|
|
68
|
+
| What you need | Import from |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `deny`, `redirect`, `getHeaders`, `getCookieJar`, `createActionClient` | `@timber-js/app/server` |
|
|
71
|
+
| `Link`, `useRouter`, `usePathname`, `useActionState` | `@timber-js/app/client` |
|
|
72
|
+
| `cache`, `CacheHandler` | `@timber-js/app/cache` |
|
|
73
|
+
| `defineSearchParams` | `@timber-js/app/search-params` |
|
|
74
|
+
| `defineSchema`, `defineSegmentParams` | `@timber-js/app/params` |
|
|
75
|
+
| Content collection data | `content-collections` |
|
|
76
|
+
| `Metadata` type | `@timber-js/app/server` |
|
|
77
|
+
|
|
78
|
+
There is **no** `@timber-js/app` default import. Always use the subpath exports above.
|
|
79
|
+
|
|
80
|
+
## Project Structure
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
app/ # Route tree — file-system routing
|
|
84
|
+
proxy.ts # Global request handler (like global middleware)
|
|
85
|
+
layout.tsx # Root layout
|
|
86
|
+
page.tsx # Home page
|
|
87
|
+
[segment]/
|
|
88
|
+
page.tsx # Dynamic route
|
|
89
|
+
middleware.ts # Per-segment middleware
|
|
90
|
+
access.ts # Per-segment authorization
|
|
91
|
+
denied.tsx # Rendered when access.ts denies
|
|
92
|
+
error.tsx # Error boundary for this segment
|
|
93
|
+
params.ts # Search param definitions
|
|
94
|
+
schema.ts # Segment param schemas (from @timber-js/app/params)
|
|
95
|
+
content/ # Content collections (outside route tree)
|
|
96
|
+
timber.config.ts # Framework configuration
|
|
97
|
+
mdx-components.tsx # Custom MDX component mappings (optional)
|
|
98
|
+
content-collections.ts # Collection definitions (optional)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Common Mistakes
|
|
102
|
+
|
|
103
|
+
1. **Importing from `next/*`** — there is no `next` package. Use `@timber-js/app/server` and `@timber-js/app/client`. Exception: `next/navigation`'s `redirect()` and `notFound()` are shimmed for compatibility.
|
|
104
|
+
|
|
105
|
+
2. **Adding `'use client'` to everything** — components are server components by default. Only add `'use client'` when you need browser APIs, event handlers, or React hooks like `useState`/`useEffect`.
|
|
106
|
+
|
|
107
|
+
3. **Using `fetch()` and expecting caching** — timber never patches `fetch`. Wrap data-fetching functions in `cache()` from `@timber-js/app/cache` when you want caching.
|
|
108
|
+
|
|
109
|
+
4. **Creating `loading.tsx` files** — this convention does not exist. Use `<Suspense>` with an explicit fallback where you want streaming.
|
|
110
|
+
|
|
111
|
+
5. **Using ISR patterns** (`revalidate`, `unstable_cache`) — ISR does not exist. Use `cache()` with `{ ttl: seconds, tags: ['tag'] }`.
|
|
112
|
+
|
|
113
|
+
6. **Calling `notFound()`** — use `deny(404)` from `@timber-js/app/server` instead. It sends a real HTTP 404 status code.
|
|
114
|
+
|
|
115
|
+
7. **Putting all middleware in one file** — middleware is per-segment. Place `middleware.ts` at the route level that matches the concern. Use `proxy.ts` for global concerns.
|
|
116
|
+
|
|
117
|
+
8. **Assuming `useSearchParams()` exists** — use `defineSearchParams()` in a `params.ts` file with Zod schemas, then access via `.get()` (server) or `.useQueryStates()` (client).
|
|
118
|
+
|
|
119
|
+
## Quick Patterns
|
|
120
|
+
|
|
121
|
+
### Data fetching with caching
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { cache } from '@timber-js/app/cache';
|
|
125
|
+
|
|
126
|
+
const getProducts = cache(
|
|
127
|
+
async () => db.products.findMany(),
|
|
128
|
+
{ ttl: 300, tags: ['products'] }
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
export default async function Page() {
|
|
132
|
+
const products = await getProducts();
|
|
133
|
+
return <ProductList products={products} />;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 404 handling
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
import { deny } from '@timber-js/app/server';
|
|
141
|
+
|
|
142
|
+
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
|
143
|
+
const { id } = await params;
|
|
144
|
+
const item = await getItem(id);
|
|
145
|
+
if (!item) deny(404);
|
|
146
|
+
return <Item item={item} />;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Per-segment middleware
|
|
151
|
+
|
|
152
|
+
```ts title="app/dashboard/middleware.ts"
|
|
153
|
+
import type { MiddlewareContext } from '@timber-js/app/server';
|
|
154
|
+
|
|
155
|
+
export default async function middleware(ctx: MiddlewareContext) {
|
|
156
|
+
ctx.headers.set('X-Dashboard', 'true');
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Access control
|
|
161
|
+
|
|
162
|
+
```ts title="app/(auth)/access.ts"
|
|
163
|
+
import { redirect, getCookieJar } from '@timber-js/app/server';
|
|
164
|
+
|
|
165
|
+
export default async function access() {
|
|
166
|
+
const session = getSession(getCookieJar());
|
|
167
|
+
if (!session) redirect('/login');
|
|
168
|
+
}
|
|
169
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.156",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -125,9 +125,9 @@
|
|
|
125
125
|
"nitro": "3.0.260610-beta"
|
|
126
126
|
},
|
|
127
127
|
"peerDependencies": {
|
|
128
|
-
"@content-collections/core": "^0.14.0",
|
|
128
|
+
"@content-collections/core": "^0.14.0 || ^0.15.0",
|
|
129
129
|
"@content-collections/mdx": "^0.2.0",
|
|
130
|
-
"@content-collections/vite": "^0.2.0",
|
|
130
|
+
"@content-collections/vite": "^0.2.0 || ^0.3.0",
|
|
131
131
|
"@mdx-js/rollup": "^3.0.0",
|
|
132
132
|
"@vitejs/plugin-react": "^6.0.0",
|
|
133
133
|
"@vitejs/plugin-rsc": ">=0.5.27",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// ../client/, or Vite-dependent code. See adapters/shared.ts header.
|
|
13
13
|
|
|
14
14
|
import { writeFile, mkdir, cp, readdir, readFile } from 'node:fs/promises';
|
|
15
|
-
import { join, basename } from 'node:path';
|
|
15
|
+
import { join, basename, relative } from 'node:path';
|
|
16
16
|
import { generateHeadersFile } from './shared.js';
|
|
17
17
|
import type { TimberConfig } from './types';
|
|
18
18
|
|
|
@@ -74,7 +74,11 @@ export async function runSharedBuildSteps(opts: BuildOutputBaseOptions): Promise
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
// 3. Write _headers file for static asset cache control.
|
|
77
|
-
|
|
77
|
+
// Scan for unhashed files so they get per-file Cache-Control rules in the
|
|
78
|
+
// _headers file. On platforms like Cloudflare Workers Static Assets, the
|
|
79
|
+
// worker never sees static file requests — _headers is the only mechanism.
|
|
80
|
+
const unhashedPaths = await findUnhashedStaticFiles(publicDir);
|
|
81
|
+
await writeFile(join(publicDir, '_headers'), generateHeadersFile(unhashedPaths));
|
|
78
82
|
|
|
79
83
|
// 4. Copy RSC + SSR server bundles into the output directory.
|
|
80
84
|
await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });
|
|
@@ -93,6 +97,52 @@ export async function runSharedBuildSteps(opts: BuildOutputBaseOptions): Promise
|
|
|
93
97
|
return publicDir;
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Find unhashed static files in the public directory that need per-file
|
|
102
|
+
* Cache-Control rules in the `_headers` file.
|
|
103
|
+
*
|
|
104
|
+
* Excludes:
|
|
105
|
+
* - Files under `assets/` and `_timber/` (covered by wildcard rules)
|
|
106
|
+
* - Underscore-prefixed files (`_headers`, `_worker.js`, etc.)
|
|
107
|
+
*
|
|
108
|
+
* @internal Exported for testing.
|
|
109
|
+
*/
|
|
110
|
+
// Well-known metadata files that should be prioritized when capping
|
|
111
|
+
// _headers rules, so they're never dropped in favor of less important files.
|
|
112
|
+
const PRIORITY_FILES = new Set([
|
|
113
|
+
'/favicon.ico',
|
|
114
|
+
'/robots.txt',
|
|
115
|
+
'/sitemap.xml',
|
|
116
|
+
'/manifest.json',
|
|
117
|
+
'/manifest.webmanifest',
|
|
118
|
+
'/apple-touch-icon.png',
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export async function findUnhashedStaticFiles(publicDir: string): Promise<string[]> {
|
|
122
|
+
let files: string[];
|
|
123
|
+
try {
|
|
124
|
+
files = await collectFiles(publicDir);
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const priority: string[] = [];
|
|
130
|
+
const rest: string[] = [];
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const rel = relative(publicDir, file).replace(/\\/g, '/');
|
|
133
|
+
if (rel.startsWith('assets/') || rel.startsWith('_timber/')) continue;
|
|
134
|
+
if (basename(rel).startsWith('_')) continue;
|
|
135
|
+
const urlPath = '/' + rel;
|
|
136
|
+
if (PRIORITY_FILES.has(urlPath)) {
|
|
137
|
+
priority.push(urlPath);
|
|
138
|
+
} else {
|
|
139
|
+
rest.push(urlPath);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...priority.sort(), ...rest.sort()];
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
/**
|
|
97
147
|
* Scan client JS files for references to .wasm filenames.
|
|
98
148
|
* Returns the set of WASM basenames that are actually imported/referenced
|
|
@@ -338,12 +338,42 @@ if (!bindingsAls) {
|
|
|
338
338
|
|
|
339
339
|
export default {
|
|
340
340
|
${handlersSpread} async fetch(request, env, ctx) {
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
341
|
+
// Edge-cache GET/HEAD responses via the Cache API. RSC navigation
|
|
342
|
+
// requests (Accept: text/x-component) are excluded — they vary by
|
|
343
|
+
// X-Timber-State-Tree which the cache key doesn't capture.
|
|
344
|
+
const isRsc = request.headers.get('Accept')?.includes('text/x-component')
|
|
345
|
+
const reqCc = request.headers.get('Cache-Control') || ''
|
|
346
|
+
const bypassCache = reqCc.includes('no-cache') || reqCc.includes('max-age=0')
|
|
347
|
+
|| request.headers.has('Pragma')
|
|
348
|
+
const cacheable = !isRsc && !bypassCache
|
|
349
|
+
&& (request.method === 'GET' || request.method === 'HEAD')
|
|
350
|
+
const cache = cacheable && typeof caches !== 'undefined' ? caches.default : null
|
|
351
|
+
if (cache) {
|
|
352
|
+
const cached = await cache.match(request)
|
|
353
|
+
if (cached) return cached
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const response = await bindingsAls.run(env, () =>
|
|
345
357
|
runWithWaitUntil((promise) => ctx.waitUntil(promise), () => handler(request))
|
|
346
358
|
)
|
|
359
|
+
|
|
360
|
+
// Cache public responses at the edge. A response is edge-cacheable if
|
|
361
|
+
// it has 'public' or 's-maxage' (a shared-cache TTL directive) and no
|
|
362
|
+
// 'no-store' or 'private'. Cloudflare's cache.put() only accepts GET
|
|
363
|
+
// requests and rejects 206, so only store GET 200s. HEAD requests
|
|
364
|
+
// benefit from cache.match() which handles HEAD→GET matching automatically.
|
|
365
|
+
// See design/25-production-deployments.md §"CDN / Edge Cache".
|
|
366
|
+
if (cache && request.method === 'GET' && response.status === 200) {
|
|
367
|
+
const cc = response.headers.get('Cache-Control')
|
|
368
|
+
const vary = response.headers.get('Vary')
|
|
369
|
+
if (cc && !cc.includes('no-store') && !cc.includes('private')
|
|
370
|
+
&& (cc.includes('public') || cc.includes('s-maxage'))
|
|
371
|
+
&& vary !== '*' && !response.headers.has('Set-Cookie')) {
|
|
372
|
+
ctx.waitUntil(cache.put(request, response.clone()))
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return response
|
|
347
377
|
}
|
|
348
378
|
}
|
|
349
379
|
`;
|
package/src/adapters/shared.ts
CHANGED
|
@@ -24,17 +24,45 @@ export const STATIC_CACHE = 'public, max-age=3600, must-revalidate';
|
|
|
24
24
|
* Static Assets, Cloudflare Pages, and Netlify. It maps URL patterns to
|
|
25
25
|
* HTTP response headers.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Only hashed (immutable) asset paths are listed here by default. When
|
|
28
|
+
* `unhashedPaths` is provided, per-file rules are appended for unhashed
|
|
29
|
+
* static assets (favicon.ico, robots.txt, etc.) so they get Cache-Control
|
|
30
|
+
* on platforms like Cloudflare Workers Static Assets where the server
|
|
31
|
+
* handler never sees static file requests.
|
|
32
|
+
*
|
|
33
|
+
* A `/*` catch-all is NOT used — it would be additive on Cloudflare,
|
|
34
|
+
* producing duplicate Cache-Control directives on hashed assets
|
|
35
|
+
* (invalid per RFC 9111).
|
|
29
36
|
*/
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
// Cloudflare Workers Static Assets caps _headers at 100 rules.
|
|
38
|
+
// Reserve 2 for the wildcard hashed-asset rules.
|
|
39
|
+
const MAX_UNHASHED_RULES = 98;
|
|
40
|
+
|
|
41
|
+
export function generateHeadersFile(unhashedPaths?: string[]): string {
|
|
42
|
+
let content = `# Auto-generated by @timber-js/app — static asset cache headers.
|
|
32
43
|
# See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
33
44
|
|
|
34
45
|
/assets/*
|
|
35
46
|
Cache-Control: ${IMMUTABLE_CACHE}
|
|
36
47
|
|
|
37
|
-
/*
|
|
38
|
-
Cache-Control: ${
|
|
48
|
+
/_timber/fonts/*
|
|
49
|
+
Cache-Control: ${IMMUTABLE_CACHE}
|
|
39
50
|
`;
|
|
51
|
+
|
|
52
|
+
if (unhashedPaths && unhashedPaths.length > 0) {
|
|
53
|
+
const capped = unhashedPaths.slice(0, MAX_UNHASHED_RULES);
|
|
54
|
+
if (unhashedPaths.length > MAX_UNHASHED_RULES) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[timber] _headers: ${unhashedPaths.length} unhashed static files found, ` +
|
|
57
|
+
`but Cloudflare limits _headers to 100 rules. Only the first ${MAX_UNHASHED_RULES} ` +
|
|
58
|
+
`unhashed files will get Cache-Control headers.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
content += `\n# Unhashed static assets — short-lived cache with revalidation\n`;
|
|
62
|
+
for (const p of capped) {
|
|
63
|
+
content += `\n${p}\n Cache-Control: ${STATIC_CACHE}\n`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return content;
|
|
40
68
|
}
|
package/src/fonts/google.ts
CHANGED
|
@@ -21,6 +21,28 @@ import { join } from 'node:path';
|
|
|
21
21
|
import type { ExtractedFont, FontFaceDescriptor } from './types.js';
|
|
22
22
|
import { fontUrlPrefix } from './paths.js';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Extract a recognizable error code (ETIMEDOUT, ENOTFOUND, etc.) from a
|
|
26
|
+
* network error's cause chain. Node's undici-based `fetch` wraps the
|
|
27
|
+
* system error inside `cause` (sometimes as an AggregateError).
|
|
28
|
+
*/
|
|
29
|
+
function extractNetworkErrorCode(err: unknown): string | undefined {
|
|
30
|
+
if (!(err instanceof Error)) return undefined;
|
|
31
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
32
|
+
if (code) return code;
|
|
33
|
+
const cause = (err as { cause?: unknown }).cause;
|
|
34
|
+
if (cause instanceof AggregateError) {
|
|
35
|
+
for (const inner of cause.errors) {
|
|
36
|
+
const innerCode = (inner as NodeJS.ErrnoException).code;
|
|
37
|
+
if (innerCode) return innerCode;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (cause instanceof Error) {
|
|
41
|
+
return (cause as NodeJS.ErrnoException).code;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
/** Google Fonts CSS API v2 base URL. */
|
|
25
47
|
const GOOGLE_FONTS_API = 'https://fonts.googleapis.com/css2';
|
|
26
48
|
|
|
@@ -112,9 +134,20 @@ export function buildGoogleFontsUrl(font: ExtractedFont): string {
|
|
|
112
134
|
* We parse each block to extract the font URL, unicode-range, and subset label.
|
|
113
135
|
*/
|
|
114
136
|
export async function fetchGoogleFontsCss(url: string): Promise<GoogleFontFace[]> {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
137
|
+
let response: Response;
|
|
138
|
+
try {
|
|
139
|
+
response = await fetch(url, {
|
|
140
|
+
headers: { 'User-Agent': WOFF2_USER_AGENT },
|
|
141
|
+
});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const code = extractNetworkErrorCode(err);
|
|
144
|
+
throw new Error(
|
|
145
|
+
`[timber] Failed to fetch Google Fonts CSS from ${url}` +
|
|
146
|
+
(code ? ` (${code})` : '') +
|
|
147
|
+
`. Build requires internet access for font optimization.` +
|
|
148
|
+
` If building offline, set TIMBER_SKIP_FONTS=1 to skip font downloading.`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
118
151
|
|
|
119
152
|
if (!response.ok) {
|
|
120
153
|
throw new Error(
|
|
@@ -205,7 +238,18 @@ export function cacheKey(face: GoogleFontFace): string {
|
|
|
205
238
|
* Download a single font file from its URL.
|
|
206
239
|
*/
|
|
207
240
|
export async function downloadFontFile(url: string): Promise<Buffer> {
|
|
208
|
-
|
|
241
|
+
let response: Response;
|
|
242
|
+
try {
|
|
243
|
+
response = await fetch(url);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const code = extractNetworkErrorCode(err);
|
|
246
|
+
throw new Error(
|
|
247
|
+
`[timber] Failed to download font file from ${url}` +
|
|
248
|
+
(code ? ` (${code})` : '') +
|
|
249
|
+
`. Build requires internet access for font optimization.` +
|
|
250
|
+
` If building offline, set TIMBER_SKIP_FONTS=1 to skip font downloading.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
209
253
|
if (!response.ok) {
|
|
210
254
|
throw new Error(`Failed to download font from ${url}: ${response.status}`);
|
|
211
255
|
}
|
|
@@ -232,7 +276,14 @@ export async function downloadAndCacheFonts(
|
|
|
232
276
|
|
|
233
277
|
for (const font of googleFonts) {
|
|
234
278
|
const apiUrl = buildGoogleFontsUrl(font);
|
|
235
|
-
|
|
279
|
+
let faces: GoogleFontFace[];
|
|
280
|
+
try {
|
|
281
|
+
faces = await fetchGoogleFontsCss(apiUrl);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`[timber] Failed to download font '${font.family}': ${err instanceof Error ? err.message : String(err)}`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
236
287
|
const filtered = filterBySubsets(faces, font.subsets);
|
|
237
288
|
|
|
238
289
|
for (const face of filtered) {
|
package/src/plugins/fonts.ts
CHANGED
|
@@ -159,9 +159,15 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
159
159
|
return '';
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
const skipFonts = process.env.TIMBER_SKIP_FONTS === '1';
|
|
163
|
+
|
|
162
164
|
for (const font of pipeline.googleFonts()) {
|
|
163
165
|
if (font.importer !== importer) continue;
|
|
164
166
|
if (pipeline.hasFaces(font.id)) continue;
|
|
167
|
+
if (skipFonts && !ctx.dev) {
|
|
168
|
+
console.warn(`[timber-fonts] Skipping font "${font.family}" (TIMBER_SKIP_FONTS=1)`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
165
171
|
try {
|
|
166
172
|
if (ctx.dev) {
|
|
167
173
|
const faces = await resolveDevFontFaces(font);
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* Unhashed assets (e.g. /favicon.ico) get a shorter cache with revalidation:
|
|
9
9
|
* Cache-Control: public, max-age=3600, must-revalidate
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* The `_headers` file lists hashed asset paths (/assets/*, /_timber/fonts/*) as
|
|
12
|
+
* wildcard rules and unhashed files as per-file rules. At runtime, adapters
|
|
13
|
+
* that see static file requests (nitro, preview) also set Cache-Control via
|
|
14
|
+
* `getAssetCacheControl()`. On Cloudflare Workers Static Assets, the worker
|
|
15
|
+
* never sees static file requests — the `_headers` file is the only mechanism.
|
|
16
|
+
* Page responses are NOT affected — those are controlled by the developer
|
|
17
|
+
* via middleware.ts or proxy.ts.
|
|
14
18
|
*
|
|
15
19
|
* Design docs: 18-build-system.md, 06-caching.md
|
|
16
20
|
*/
|
|
@@ -290,13 +290,21 @@ export async function handleRequest(
|
|
|
290
290
|
// immutable header bag (e.g. user returns Response.redirect()).
|
|
291
291
|
if (handlerResult instanceof Response) {
|
|
292
292
|
if (method === 'HEAD') {
|
|
293
|
+
const headHeaders = new Headers(handlerResult.headers);
|
|
294
|
+
if (handlerResult.ok && !headHeaders.has('Cache-Control')) {
|
|
295
|
+
headHeaders.set('Cache-Control', 'public, max-age=14400, must-revalidate');
|
|
296
|
+
}
|
|
293
297
|
return new Response(null, {
|
|
294
298
|
status: handlerResult.status,
|
|
295
299
|
statusText: handlerResult.statusText,
|
|
296
|
-
headers:
|
|
300
|
+
headers: headHeaders,
|
|
297
301
|
});
|
|
298
302
|
}
|
|
299
|
-
|
|
303
|
+
const res = cloneWithMutableHeaders(handlerResult);
|
|
304
|
+
if (res.ok && !res.headers.has('Cache-Control')) {
|
|
305
|
+
res.headers.set('Cache-Control', 'public, max-age=14400, must-revalidate');
|
|
306
|
+
}
|
|
307
|
+
return res;
|
|
300
308
|
}
|
|
301
309
|
// Otherwise, serialize based on content type. The type discriminator
|
|
302
310
|
// here is the metadata route's declared content type (from the file
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"build-output-helper-B20ZuTSA.js","names":[],"sources":["../../src/adapters/shared.ts","../../src/adapters/build-output-helper.ts"],"sourcesContent":["// Shared adapter utilities — leaf module with zero deps on server/, client/,\n// or any Vite-dependent code.\n//\n// Adapters are loaded by Node at Vite startup time, before Vite's module\n// resolver is available. This module uses only Node built-ins and can be\n// imported by both adapters via a relative path.\n//\n// IMPORTANT: Do NOT add imports from ../server/, ../client/, ../plugins/,\n// or any module that transitively depends on Vite. A test in\n// tests/adapters/shared-no-forbidden-imports.test.ts enforces this.\n\n// ─── Static asset cache headers ──────────────────────────────────────────\n\n/** Cache-Control value for hashed (immutable) assets. */\nexport const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\n\n/** Cache-Control value for unhashed static assets. */\nexport const STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\n/**\n * Generate a `_headers` file for static asset cache control.\n *\n * The `_headers` file is a platform convention supported by Cloudflare Workers\n * Static Assets, Cloudflare Pages, and Netlify. It maps URL patterns to\n * HTTP response headers.\n *\n * Vite places all hashed chunks under `/assets/` — these get immutable caching.\n * Everything else (favicon.ico, robots.txt, etc.) gets a shorter cache.\n */\nexport function generateHeadersFile(): string {\n return `# Auto-generated by @timber-js/app — static asset cache headers.\n# See design/25-production-deployments.md §\"CDN / Edge Cache\"\n\n/assets/*\n Cache-Control: ${IMMUTABLE_CACHE}\n\n/*\n Cache-Control: ${STATIC_CACHE}\n`;\n}\n","// Shared build-output helper for adapter buildOutput() methods.\n//\n// Steps 1–5 of every adapter's buildOutput are platform-agnostic:\n// 1. Copy client assets to the public/static directory\n// 2. Write _headers file for static asset cache control\n// 3. Copy RSC + SSR server bundles\n// 4. Write manifest-init module (if present)\n//\n// Only the final steps (entry codegen, platform config) vary per adapter.\n//\n// IMPORTANT: This module must remain a leaf — no imports from ../server/,\n// ../client/, or Vite-dependent code. See adapters/shared.ts header.\n\nimport { writeFile, mkdir, cp, readdir, readFile } from 'node:fs/promises';\nimport { join, basename } from 'node:path';\nimport { generateHeadersFile } from './shared.js';\nimport type { TimberConfig } from './types';\n\n/** Options for the shared build output steps. */\nexport interface BuildOutputBaseOptions {\n /** Resolved timber config. */\n config: TimberConfig;\n /** Root build directory (e.g. `.timber/build`). */\n buildDir: string;\n /** Adapter-specific output directory (e.g. `.timber/build/nitro`). */\n outDir: string;\n /**\n * Name of the public/static directory within outDir.\n * Nitro uses 'public', Cloudflare uses 'static'.\n */\n publicDirName: string;\n}\n\n/**\n * Run the platform-agnostic build output steps shared by all adapters:\n *\n * 1. Create the output directory\n * 2. Copy client assets to the public/static directory\n * 3. Write the `_headers` file for static asset cache control\n * 4. Copy RSC and SSR server bundles into the output directory\n * 5. Write the manifest-init module if present\n *\n * Returns the resolved public directory path for further adapter-specific use.\n */\nexport async function runSharedBuildSteps(opts: BuildOutputBaseOptions): Promise<string> {\n const { config, buildDir, outDir, publicDirName } = opts;\n\n // 1. Create the output directory.\n await mkdir(outDir, { recursive: true });\n\n // 2. Copy client assets to public/static directory.\n // When client JavaScript is disabled, skip .js files — only CSS,\n // fonts, images, and other static assets are needed.\n const clientDir = join(buildDir, 'client');\n const publicDir = join(outDir, publicDirName);\n await mkdir(publicDir, { recursive: true });\n // Find WASM files that are referenced by client JS (legitimate client WASM)\n // vs orphaned server-only WASM (e.g. @takumi-rs for OG image generation).\n // Only strip orphaned WASM to avoid breaking client-side WASM usage.\n const referencedWasm = await findReferencedWasm(clientDir);\n\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: (src: string) => {\n if (src.endsWith('.wasm') && !referencedWasm.has(basename(src))) {\n return false;\n }\n if (config.clientJavascriptDisabled && src.endsWith('.js')) return false;\n return true;\n },\n }).catch((e: NodeJS.ErrnoException) => {\n // Client dir may not exist when client JavaScript is disabled\n if (e.code !== 'ENOENT') throw e;\n });\n\n // 3. Write _headers file for static asset cache control.\n await writeFile(join(publicDir, '_headers'), generateHeadersFile());\n\n // 4. Copy RSC + SSR server bundles into the output directory.\n await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });\n await cp(join(buildDir, 'ssr'), join(outDir, 'ssr'), { recursive: true }).catch(\n (e: NodeJS.ErrnoException) => {\n // SSR dir may not exist for static-only builds\n if (e.code !== 'ENOENT') throw e;\n }\n );\n\n // 5. Write manifest-init module if present.\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n return publicDir;\n}\n\n/**\n * Scan client JS files for references to .wasm filenames.\n * Returns the set of WASM basenames that are actually imported/referenced\n * by client code and must be preserved in the static output.\n */\nasync function findReferencedWasm(clientDir: string): Promise<Set<string>> {\n const referenced = new Set<string>();\n\n let files: string[];\n try {\n files = await collectFiles(clientDir);\n } catch {\n return referenced;\n }\n\n const jsFiles = files.filter((f) => f.endsWith('.js'));\n const wasmFiles = files.filter((f) => f.endsWith('.wasm')).map((f) => basename(f));\n if (wasmFiles.length === 0) return referenced;\n\n for (const jsFile of jsFiles) {\n const content = await readFile(jsFile, 'utf-8');\n for (const wasmName of wasmFiles) {\n if (content.includes(wasmName)) {\n referenced.add(wasmName);\n }\n }\n }\n\n return referenced;\n}\n\nasync function collectFiles(dir: string): Promise<string[]> {\n const result: string[] = [];\n const entries = await readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = join(dir, entry.name);\n if (entry.isDirectory()) {\n result.push(...(await collectFiles(fullPath)));\n } else {\n result.push(fullPath);\n }\n }\n return result;\n}\n"],"mappings":";;;;AAcA,IAAa,kBAAkB;;AAG/B,IAAa,eAAe;;;;;;;;;;;AAY5B,SAAgB,sBAA8B;CAC5C,OAAO;;;;mBAIU,gBAAgB;;;mBAGhB,aAAa;;AAEhC;;;;;;;;;;;;;;ACKA,eAAsB,oBAAoB,MAA+C;CACvF,MAAM,EAAE,QAAQ,UAAU,QAAQ,kBAAkB;CAGpD,MAAM,MAAM,QAAQ,EAAE,WAAW,KAAK,CAAC;CAKvC,MAAM,YAAY,KAAK,UAAU,QAAQ;CACzC,MAAM,YAAY,KAAK,QAAQ,aAAa;CAC5C,MAAM,MAAM,WAAW,EAAE,WAAW,KAAK,CAAC;CAI1C,MAAM,iBAAiB,MAAM,mBAAmB,SAAS;CAEzD,MAAM,GAAG,WAAW,WAAW;EAC7B,WAAW;EACX,SAAS,QAAgB;GACvB,IAAI,IAAI,SAAS,OAAO,KAAK,CAAC,eAAe,IAAI,SAAS,GAAG,CAAC,GAC5D,OAAO;GAET,IAAI,OAAO,4BAA4B,IAAI,SAAS,KAAK,GAAG,OAAO;GACnE,OAAO;EACT;CACF,CAAC,EAAE,OAAO,MAA6B;EAErC,IAAI,EAAE,SAAS,UAAU,MAAM;CACjC,CAAC;CAGD,MAAM,UAAU,KAAK,WAAW,UAAU,GAAG,oBAAoB,CAAC;CAGlE,MAAM,GAAG,KAAK,UAAU,KAAK,GAAG,KAAK,QAAQ,KAAK,GAAG,EAAE,WAAW,KAAK,CAAC;CACxE,MAAM,GAAG,KAAK,UAAU,KAAK,GAAG,KAAK,QAAQ,KAAK,GAAG,EAAE,WAAW,KAAK,CAAC,EAAE,OACvE,MAA6B;EAE5B,IAAI,EAAE,SAAS,UAAU,MAAM;CACjC,CACF;CAGA,IAAI,OAAO,cACT,MAAM,UAAU,KAAK,QAAQ,0BAA0B,GAAG,OAAO,YAAY;CAG/E,OAAO;AACT;;;;;;AAOA,eAAe,mBAAmB,WAAyC;CACzE,MAAM,6BAAa,IAAI,IAAY;CAEnC,IAAI;CACJ,IAAI;EACF,QAAQ,MAAM,aAAa,SAAS;CACtC,QAAQ;EACN,OAAO;CACT;CAEA,MAAM,UAAU,MAAM,QAAQ,MAAM,EAAE,SAAS,KAAK,CAAC;CACrD,MAAM,YAAY,MAAM,QAAQ,MAAM,EAAE,SAAS,OAAO,CAAC,EAAE,KAAK,MAAM,SAAS,CAAC,CAAC;CACjF,IAAI,UAAU,WAAW,GAAG,OAAO;CAEnC,KAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,UAAU,MAAM,SAAS,QAAQ,OAAO;EAC9C,KAAK,MAAM,YAAY,WACrB,IAAI,QAAQ,SAAS,QAAQ,GAC3B,WAAW,IAAI,QAAQ;CAG7B;CAEA,OAAO;AACT;AAEA,eAAe,aAAa,KAAgC;CAC1D,MAAM,SAAmB,CAAC;CAC1B,MAAM,UAAU,MAAM,QAAQ,KAAK,EAAE,eAAe,KAAK,CAAC;CAC1D,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,WAAW,KAAK,KAAK,MAAM,IAAI;EACrC,IAAI,MAAM,YAAY,GACpB,OAAO,KAAK,GAAI,MAAM,aAAa,QAAQ,CAAE;OAE7C,OAAO,KAAK,QAAQ;CAExB;CACA,OAAO;AACT"}
|