fontdue-js 3.0.0-alpha7 → 3.0.0-alpha8
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/__tests__/nextAdapter.test.js +307 -0
- package/dist/next/config.d.ts +45 -0
- package/dist/next/config.js +180 -0
- package/dist/next/image-loader.d.ts +7 -0
- package/dist/next/image-loader.js +39 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +10 -0
- package/dist/next/revalidate.d.ts +1 -0
- package/dist/next/revalidate.js +37 -0
- package/dist/next/tenant.d.ts +18 -0
- package/dist/next/tenant.js +105 -0
- package/dist/relay/environment.js +1 -1
- package/dist/vite.js +2 -0
- package/package.json +5 -2
- package/types/next-cache.d.ts +6 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// The next adapter modules read process.env at module load, so each test
|
|
4
|
+
// group stubs the env and re-imports them fresh.
|
|
5
|
+
async function importTenant() {
|
|
6
|
+
return await import("../next/tenant.js");
|
|
7
|
+
}
|
|
8
|
+
async function importConfig() {
|
|
9
|
+
return await import("../next/config.js");
|
|
10
|
+
}
|
|
11
|
+
async function importRevalidate() {
|
|
12
|
+
return await import("../next/revalidate.js");
|
|
13
|
+
}
|
|
14
|
+
const revalidateTag = vi.fn();
|
|
15
|
+
vi.mock('next/cache', () => ({
|
|
16
|
+
revalidateTag: tag => revalidateTag(tag)
|
|
17
|
+
}));
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
revalidateTag.mockClear();
|
|
21
|
+
vi.unstubAllEnvs();
|
|
22
|
+
});
|
|
23
|
+
function stubSingleTenant() {
|
|
24
|
+
let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
|
|
25
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', url);
|
|
26
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
27
|
+
vi.stubEnv('FONTDUE_ORIGIN', '');
|
|
28
|
+
vi.stubEnv('FONTDUE_PROXY_SECRET', '');
|
|
29
|
+
}
|
|
30
|
+
function stubMultiTenant() {
|
|
31
|
+
let {
|
|
32
|
+
origin = '',
|
|
33
|
+
secret = ''
|
|
34
|
+
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
35
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
36
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '1');
|
|
37
|
+
vi.stubEnv('FONTDUE_ORIGIN', origin);
|
|
38
|
+
vi.stubEnv('FONTDUE_PROXY_SECRET', secret);
|
|
39
|
+
}
|
|
40
|
+
describe('isValidDomain', () => {
|
|
41
|
+
it('accepts plain dotted hostnames and rejects everything else', async () => {
|
|
42
|
+
stubMultiTenant();
|
|
43
|
+
const {
|
|
44
|
+
isValidDomain
|
|
45
|
+
} = await importTenant();
|
|
46
|
+
expect(isValidDomain('acme.fontdue.com')).toBe(true);
|
|
47
|
+
expect(isValidDomain('a-b.example')).toBe(true);
|
|
48
|
+
expect(isValidDomain('localhost')).toBe(false); // no dot
|
|
49
|
+
expect(isValidDomain('acme.fontdue.com:3000')).toBe(false); // port
|
|
50
|
+
expect(isValidDomain('acme.fontdue.com/x')).toBe(false); // path
|
|
51
|
+
expect(isValidDomain('-bad.example')).toBe(false);
|
|
52
|
+
expect(isValidDomain('a'.repeat(254) + '.example')).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('fontdueEndpoint', () => {
|
|
56
|
+
it('single-tenant: targets NEXT_PUBLIC_FONTDUE_URL with no headers', async () => {
|
|
57
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
58
|
+
const {
|
|
59
|
+
fontdueEndpoint
|
|
60
|
+
} = await importTenant();
|
|
61
|
+
expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
|
|
62
|
+
origin: 'https://acme.fontdue.com',
|
|
63
|
+
headers: {},
|
|
64
|
+
tags: ['graphql', 'graphql:acme.fontdue.com']
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
it('single-tenant: throws when NEXT_PUBLIC_FONTDUE_URL is missing', async () => {
|
|
68
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
69
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
70
|
+
const {
|
|
71
|
+
fontdueEndpoint
|
|
72
|
+
} = await importTenant();
|
|
73
|
+
expect(() => fontdueEndpoint('acme.fontdue.com')).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
74
|
+
});
|
|
75
|
+
it('multi-tenant: forwards the host to FONTDUE_ORIGIN with the proxy secret', async () => {
|
|
76
|
+
stubMultiTenant({
|
|
77
|
+
origin: 'http://app:4000',
|
|
78
|
+
secret: 's3cret'
|
|
79
|
+
});
|
|
80
|
+
const {
|
|
81
|
+
fontdueEndpoint
|
|
82
|
+
} = await importTenant();
|
|
83
|
+
expect(fontdueEndpoint('acme.fontdue.com')).toEqual({
|
|
84
|
+
origin: 'http://app:4000',
|
|
85
|
+
headers: {
|
|
86
|
+
'x-forwarded-host': 'acme.fontdue.com',
|
|
87
|
+
'x-fontdue-proxy-secret': 's3cret'
|
|
88
|
+
},
|
|
89
|
+
tags: ['graphql', 'graphql:acme.fontdue.com']
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
it('multi-tenant: omits the secret header when unset', async () => {
|
|
93
|
+
stubMultiTenant({
|
|
94
|
+
origin: 'http://app:4000'
|
|
95
|
+
});
|
|
96
|
+
const {
|
|
97
|
+
fontdueEndpoint
|
|
98
|
+
} = await importTenant();
|
|
99
|
+
expect(fontdueEndpoint('acme.fontdue.com').headers).toEqual({
|
|
100
|
+
'x-forwarded-host': 'acme.fontdue.com'
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
it('multi-tenant: falls back to the tenant public URL without FONTDUE_ORIGIN', async () => {
|
|
104
|
+
stubMultiTenant();
|
|
105
|
+
const {
|
|
106
|
+
fontdueEndpoint
|
|
107
|
+
} = await importTenant();
|
|
108
|
+
expect(fontdueEndpoint('acme.fontdue.com').origin).toBe('https://acme.fontdue.com');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('configureFontdueRender', () => {
|
|
112
|
+
it('rejects invalid domains with null and sets no config', async () => {
|
|
113
|
+
stubMultiTenant({
|
|
114
|
+
origin: 'http://app:4000'
|
|
115
|
+
});
|
|
116
|
+
const {
|
|
117
|
+
configureFontdueRender
|
|
118
|
+
} = await importTenant();
|
|
119
|
+
const {
|
|
120
|
+
getFontdueServerConfig
|
|
121
|
+
} = await import("../relay/serverConfig.js");
|
|
122
|
+
expect(configureFontdueRender('not a domain')).toBeNull();
|
|
123
|
+
expect(getFontdueServerConfig()).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
it('sets the per-render server config and returns the endpoint', async () => {
|
|
126
|
+
stubMultiTenant({
|
|
127
|
+
origin: 'http://app:4000',
|
|
128
|
+
secret: 's3cret'
|
|
129
|
+
});
|
|
130
|
+
const {
|
|
131
|
+
configureFontdueRender
|
|
132
|
+
} = await importTenant();
|
|
133
|
+
const {
|
|
134
|
+
getFontdueServerConfig
|
|
135
|
+
} = await import("../relay/serverConfig.js");
|
|
136
|
+
const endpoint = configureFontdueRender('acme.fontdue.com');
|
|
137
|
+
expect(endpoint === null || endpoint === void 0 ? void 0 : endpoint.origin).toBe('http://app:4000');
|
|
138
|
+
// Outside an RSC render React.cache doesn't memoize, so the write is a
|
|
139
|
+
// no-op here — but the config passed must still be shaped correctly, so
|
|
140
|
+
// assert via fontdueServerConfig instead.
|
|
141
|
+
const {
|
|
142
|
+
fontdueServerConfig
|
|
143
|
+
} = await importTenant();
|
|
144
|
+
expect(fontdueServerConfig('acme.fontdue.com')).toEqual({
|
|
145
|
+
url: 'http://app:4000',
|
|
146
|
+
headers: {
|
|
147
|
+
'x-forwarded-host': 'acme.fontdue.com',
|
|
148
|
+
'x-fontdue-proxy-secret': 's3cret'
|
|
149
|
+
},
|
|
150
|
+
cacheTags: ['graphql:acme.fontdue.com']
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe('withFontdue', () => {
|
|
155
|
+
it('throws when neither mode is configured', async () => {
|
|
156
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', '');
|
|
157
|
+
vi.stubEnv('FONTDUE_MULTI_TENANT', '');
|
|
158
|
+
const {
|
|
159
|
+
withFontdue
|
|
160
|
+
} = await importConfig();
|
|
161
|
+
expect(() => withFontdue({})).toThrow(/NEXT_PUBLIC_FONTDUE_URL/);
|
|
162
|
+
});
|
|
163
|
+
it('single-tenant: rewrites every path under the constant domain', async () => {
|
|
164
|
+
stubSingleTenant('https://acme.fontdue.com');
|
|
165
|
+
const {
|
|
166
|
+
withFontdue
|
|
167
|
+
} = await importConfig();
|
|
168
|
+
const rewrites = await withFontdue({}).rewrites();
|
|
169
|
+
expect(rewrites.afterFiles).toEqual([]);
|
|
170
|
+
expect(rewrites.fallback).toEqual([]);
|
|
171
|
+
const destinations = rewrites.beforeFiles.map(r => r.destination);
|
|
172
|
+
expect(destinations).toEqual(['/acme.fontdue.com', '/acme.fontdue.com/robots.txt', '/acme.fontdue.com/sitemap.xml', '/acme.fontdue.com/:path']);
|
|
173
|
+
});
|
|
174
|
+
it('multi-tenant: emits forwarded-host rules before host rules, mutually exclusive', async () => {
|
|
175
|
+
stubMultiTenant();
|
|
176
|
+
const {
|
|
177
|
+
withFontdue
|
|
178
|
+
} = await importConfig();
|
|
179
|
+
const rewrites = await withFontdue({}).rewrites();
|
|
180
|
+
expect(rewrites.beforeFiles).toHaveLength(8);
|
|
181
|
+
const [forwarded, hostBased] = [rewrites.beforeFiles.slice(0, 4), rewrites.beforeFiles.slice(4)];
|
|
182
|
+
for (const rule of forwarded) {
|
|
183
|
+
var _rule$has;
|
|
184
|
+
expect((_rule$has = rule.has) === null || _rule$has === void 0 ? void 0 : _rule$has[0]).toMatchObject({
|
|
185
|
+
type: 'header',
|
|
186
|
+
key: 'x-forwarded-host'
|
|
187
|
+
});
|
|
188
|
+
expect(rule.missing).toBeUndefined();
|
|
189
|
+
}
|
|
190
|
+
for (const rule of hostBased) {
|
|
191
|
+
var _rule$has2, _rule$missing;
|
|
192
|
+
expect((_rule$has2 = rule.has) === null || _rule$has2 === void 0 ? void 0 : _rule$has2[0]).toMatchObject({
|
|
193
|
+
type: 'host'
|
|
194
|
+
});
|
|
195
|
+
expect((_rule$missing = rule.missing) === null || _rule$missing === void 0 ? void 0 : _rule$missing[0]).toMatchObject({
|
|
196
|
+
type: 'header',
|
|
197
|
+
key: 'x-forwarded-host'
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
it("chains the app's beforeFiles rules ahead of the tenant rules", async () => {
|
|
202
|
+
stubSingleTenant();
|
|
203
|
+
const {
|
|
204
|
+
withFontdue
|
|
205
|
+
} = await importConfig();
|
|
206
|
+
const userRule = {
|
|
207
|
+
source: '/old',
|
|
208
|
+
destination: '/new'
|
|
209
|
+
};
|
|
210
|
+
const config = withFontdue({
|
|
211
|
+
rewrites: async () => ({
|
|
212
|
+
beforeFiles: [userRule]
|
|
213
|
+
})
|
|
214
|
+
});
|
|
215
|
+
const rewrites = await config.rewrites();
|
|
216
|
+
expect(rewrites.beforeFiles[0]).toEqual(userRule);
|
|
217
|
+
expect(rewrites.beforeFiles).toHaveLength(5);
|
|
218
|
+
});
|
|
219
|
+
it("treats a plain-array rewrites() result as afterFiles, per Next's contract", async () => {
|
|
220
|
+
stubSingleTenant();
|
|
221
|
+
const {
|
|
222
|
+
withFontdue
|
|
223
|
+
} = await importConfig();
|
|
224
|
+
const userRule = {
|
|
225
|
+
source: '/old',
|
|
226
|
+
destination: '/new'
|
|
227
|
+
};
|
|
228
|
+
const rewrites = await withFontdue({
|
|
229
|
+
rewrites: async () => [userRule]
|
|
230
|
+
}).rewrites();
|
|
231
|
+
expect(rewrites.afterFiles).toEqual([userRule]);
|
|
232
|
+
expect(rewrites.beforeFiles).toHaveLength(4);
|
|
233
|
+
});
|
|
234
|
+
it('merges image settings, keeping app remotePatterns and overrides', async () => {
|
|
235
|
+
stubMultiTenant();
|
|
236
|
+
const {
|
|
237
|
+
withFontdue
|
|
238
|
+
} = await importConfig();
|
|
239
|
+
const config = withFontdue({
|
|
240
|
+
images: {
|
|
241
|
+
dangerouslyAllowSVG: false,
|
|
242
|
+
remotePatterns: [{
|
|
243
|
+
protocol: 'https',
|
|
244
|
+
hostname: 'cdn.example'
|
|
245
|
+
}]
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
expect(config.images.dangerouslyAllowSVG).toBe(false);
|
|
249
|
+
// NODE_ENV is 'test' here, i.e. not production → dev workaround active.
|
|
250
|
+
expect(config.images.unoptimized).toBe(true);
|
|
251
|
+
const hostnames = config.images.remotePatterns.map(p => p.hostname);
|
|
252
|
+
expect(hostnames).toContain('cdn.example');
|
|
253
|
+
expect(hostnames).toContain('*.fontdue.com');
|
|
254
|
+
expect(hostnames).toContain('**');
|
|
255
|
+
});
|
|
256
|
+
it('passes unrelated config through and defaults htmlLimitedBots', async () => {
|
|
257
|
+
stubSingleTenant();
|
|
258
|
+
const {
|
|
259
|
+
withFontdue
|
|
260
|
+
} = await importConfig();
|
|
261
|
+
const config = withFontdue({
|
|
262
|
+
reactStrictMode: true
|
|
263
|
+
});
|
|
264
|
+
expect(config.reactStrictMode).toBe(true);
|
|
265
|
+
expect(config.htmlLimitedBots).toEqual(/.*/);
|
|
266
|
+
expect(withFontdue({
|
|
267
|
+
htmlLimitedBots: /Googlebot/
|
|
268
|
+
}).htmlLimitedBots).toEqual(/Googlebot/);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
describe('revalidate POST', () => {
|
|
272
|
+
it('multi-tenant: purges only the tenant tag', async () => {
|
|
273
|
+
stubMultiTenant();
|
|
274
|
+
const {
|
|
275
|
+
POST
|
|
276
|
+
} = await importRevalidate();
|
|
277
|
+
const response = await POST(new Request('http://internal/api/revalidate?domain=Acme.Fontdue.com', {
|
|
278
|
+
method: 'POST'
|
|
279
|
+
}));
|
|
280
|
+
expect(response.status).toBe(200);
|
|
281
|
+
expect(revalidateTag).toHaveBeenCalledExactlyOnceWith('graphql:acme.fontdue.com');
|
|
282
|
+
});
|
|
283
|
+
it('multi-tenant: 400s on a missing or invalid domain', async () => {
|
|
284
|
+
stubMultiTenant();
|
|
285
|
+
const {
|
|
286
|
+
POST
|
|
287
|
+
} = await importRevalidate();
|
|
288
|
+
for (const url of ['http://internal/api/revalidate', 'http://internal/api/revalidate?domain=not%20a%20domain']) {
|
|
289
|
+
const response = await POST(new Request(url, {
|
|
290
|
+
method: 'POST'
|
|
291
|
+
}));
|
|
292
|
+
expect(response.status).toBe(400);
|
|
293
|
+
}
|
|
294
|
+
expect(revalidateTag).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
it('single-tenant: purges the global graphql tag', async () => {
|
|
297
|
+
stubSingleTenant();
|
|
298
|
+
const {
|
|
299
|
+
POST
|
|
300
|
+
} = await importRevalidate();
|
|
301
|
+
const response = await POST(new Request('http://internal/api/revalidate', {
|
|
302
|
+
method: 'POST'
|
|
303
|
+
}));
|
|
304
|
+
expect(response.status).toBe(200);
|
|
305
|
+
expect(revalidateTag).toHaveBeenCalledExactlyOnceWith('graphql');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
interface RouteHas {
|
|
2
|
+
type: 'header' | 'query' | 'cookie' | 'host';
|
|
3
|
+
key?: string;
|
|
4
|
+
value?: string;
|
|
5
|
+
}
|
|
6
|
+
interface Rewrite {
|
|
7
|
+
source: string;
|
|
8
|
+
destination: string;
|
|
9
|
+
has?: RouteHas[];
|
|
10
|
+
missing?: RouteHas[];
|
|
11
|
+
}
|
|
12
|
+
interface RewriteGroups {
|
|
13
|
+
beforeFiles: Rewrite[];
|
|
14
|
+
afterFiles: Rewrite[];
|
|
15
|
+
fallback: Rewrite[];
|
|
16
|
+
}
|
|
17
|
+
type RewritesResult = Rewrite[] | Partial<RewriteGroups>;
|
|
18
|
+
interface NextConfigLike {
|
|
19
|
+
rewrites?: () => Promise<RewritesResult> | RewritesResult;
|
|
20
|
+
images?: {
|
|
21
|
+
remotePatterns?: unknown[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
export declare function tenantRewrites(): Rewrite[];
|
|
27
|
+
export declare function withFontdue<C extends NextConfigLike>(nextConfig?: C): C & {
|
|
28
|
+
rewrites(): Promise<{
|
|
29
|
+
beforeFiles: Rewrite[];
|
|
30
|
+
afterFiles: Rewrite[];
|
|
31
|
+
fallback: Rewrite[];
|
|
32
|
+
}>;
|
|
33
|
+
images: {
|
|
34
|
+
remotePatterns: unknown[];
|
|
35
|
+
dangerouslyAllowSVG: boolean;
|
|
36
|
+
loader: "custom";
|
|
37
|
+
loaderFile: string;
|
|
38
|
+
} | {
|
|
39
|
+
remotePatterns: unknown[];
|
|
40
|
+
dangerouslyAllowSVG: boolean;
|
|
41
|
+
unoptimized: boolean;
|
|
42
|
+
};
|
|
43
|
+
htmlLimitedBots: RegExp;
|
|
44
|
+
};
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// withFontdue(nextConfig): next.config wrapper that installs everything a
|
|
2
|
+
// Fontdue storefront needs — host→path tenant rewrites, image settings, and
|
|
3
|
+
// workarounds for Next behaviors that would otherwise break Fontdue pages.
|
|
4
|
+
// Import it from next.config.mjs (this package is ESM):
|
|
5
|
+
//
|
|
6
|
+
// import { withFontdue } from 'fontdue-js/next/config';
|
|
7
|
+
// export default withFontdue({ /* your config */ });
|
|
8
|
+
//
|
|
9
|
+
// This module is evaluated at config-load time, so it must not import React,
|
|
10
|
+
// Relay, or anything else from the component tree.
|
|
11
|
+
|
|
12
|
+
import { relative } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
// Minimal structural types so this module doesn't need `next` installed to
|
|
16
|
+
// type-check; the shapes match next/dist/lib/load-custom-routes.
|
|
17
|
+
|
|
18
|
+
// Every page lives under the /[domain]/... route tree so Next renders and
|
|
19
|
+
// caches each tenant's pages independently. These rewrites turn the request's
|
|
20
|
+
// host into that leading path segment:
|
|
21
|
+
//
|
|
22
|
+
// acme.fontdue.com/fonts/foo → /acme.fontdue.com/fonts/foo (internal)
|
|
23
|
+
//
|
|
24
|
+
// In single-tenant mode the domain is constant (from NEXT_PUBLIC_FONTDUE_URL)
|
|
25
|
+
// so the app behaves exactly like a plain single-site Next app.
|
|
26
|
+
//
|
|
27
|
+
// This is done with config rewrites rather than middleware on purpose:
|
|
28
|
+
// middleware rewrites bypass the ISR page cache on self-hosted next start,
|
|
29
|
+
// turning every request into a full render. beforeFiles rewrites go through
|
|
30
|
+
// the normal routing layer and keep per-tenant ISR working. (beforeFiles also
|
|
31
|
+
// runs before app routes are matched, so the internal /[domain] paths can't
|
|
32
|
+
// be reached directly with a mismatching Host — /evil.com on acme's domain
|
|
33
|
+
// becomes /acme.fontdue.com/evil.com, which 404s.)
|
|
34
|
+
//
|
|
35
|
+
// X-Forwarded-Host (set by the Fontdue proxy in front of this service) wins
|
|
36
|
+
// over Host. The hostname charset is constrained in the patterns; anything
|
|
37
|
+
// else falls through to a 404.
|
|
38
|
+
//
|
|
39
|
+
// beforeFiles rules CHAIN: each rule is evaluated in order against the
|
|
40
|
+
// already-rewritten path, so a rewrite must produce a path no later rule can
|
|
41
|
+
// match. Tenant domains always contain a dot, so the catch-all path rule
|
|
42
|
+
// refuses any path whose first segment contains a dot — that makes the
|
|
43
|
+
// rewritten /acme.fontdue.com/... inert. robots.txt and sitemap.xml (dotted
|
|
44
|
+
// first segments we DO want to serve) get their own explicit rules, which run
|
|
45
|
+
// first. Side effect: a page slug containing a dot can't be routed at the
|
|
46
|
+
// top level.
|
|
47
|
+
const TENANT_HOST = '(?<tenant>[a-zA-Z0-9][a-zA-Z0-9.-]*)';
|
|
48
|
+
const DOTLESS_PATH = '/:path((?!api/|_next/|favicon\\.ico)(?![^/]*\\.[^/]*(?:/|$)).*)';
|
|
49
|
+
|
|
50
|
+
// One rule set rewriting onto `dest` (either a fixed /domain or the /:tenant
|
|
51
|
+
// capture from `has`). `conditions` is {has?, missing?}.
|
|
52
|
+
function rewriteRules(dest) {
|
|
53
|
+
let conditions = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
|
|
54
|
+
return [{
|
|
55
|
+
source: '/',
|
|
56
|
+
destination: dest,
|
|
57
|
+
...conditions
|
|
58
|
+
}, {
|
|
59
|
+
source: '/robots.txt',
|
|
60
|
+
destination: `${dest}/robots.txt`,
|
|
61
|
+
...conditions
|
|
62
|
+
}, {
|
|
63
|
+
source: '/sitemap.xml',
|
|
64
|
+
destination: `${dest}/sitemap.xml`,
|
|
65
|
+
...conditions
|
|
66
|
+
}, {
|
|
67
|
+
source: DOTLESS_PATH,
|
|
68
|
+
destination: `${dest}/:path`,
|
|
69
|
+
...conditions
|
|
70
|
+
}];
|
|
71
|
+
}
|
|
72
|
+
export function tenantRewrites() {
|
|
73
|
+
const fontdueUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
|
|
74
|
+
const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
|
|
75
|
+
if (!isMultiTenant) {
|
|
76
|
+
return rewriteRules(`/${new URL(fontdueUrl).host}`);
|
|
77
|
+
}
|
|
78
|
+
const forwardedHost = {
|
|
79
|
+
type: 'header',
|
|
80
|
+
key: 'x-forwarded-host',
|
|
81
|
+
value: `${TENANT_HOST}(:.*)?`
|
|
82
|
+
};
|
|
83
|
+
const noForwardedHost = {
|
|
84
|
+
type: 'header',
|
|
85
|
+
key: 'x-forwarded-host'
|
|
86
|
+
};
|
|
87
|
+
const host = {
|
|
88
|
+
type: 'host',
|
|
89
|
+
value: TENANT_HOST
|
|
90
|
+
};
|
|
91
|
+
return [...rewriteRules('/:tenant', {
|
|
92
|
+
has: [forwardedHost]
|
|
93
|
+
}),
|
|
94
|
+
// Host-based rules only apply when X-Forwarded-Host is absent, so the
|
|
95
|
+
// two sets can't both rewrite one request.
|
|
96
|
+
...rewriteRules('/:tenant', {
|
|
97
|
+
has: [host],
|
|
98
|
+
missing: [noForwardedHost]
|
|
99
|
+
})];
|
|
100
|
+
}
|
|
101
|
+
async function resolveRewrites(rewrites) {
|
|
102
|
+
const result = (await (rewrites === null || rewrites === void 0 ? void 0 : rewrites())) ?? [];
|
|
103
|
+
// A plain array from rewrites() is afterFiles, per Next's contract.
|
|
104
|
+
if (Array.isArray(result)) {
|
|
105
|
+
return {
|
|
106
|
+
beforeFiles: [],
|
|
107
|
+
afterFiles: result,
|
|
108
|
+
fallback: []
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
beforeFiles: result.beforeFiles ?? [],
|
|
113
|
+
afterFiles: result.afterFiles ?? [],
|
|
114
|
+
fallback: result.fallback ?? []
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export function withFontdue() {
|
|
118
|
+
let nextConfig = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
119
|
+
const fontdueUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
|
|
120
|
+
const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
|
|
121
|
+
if (!isMultiTenant && !fontdueUrl) {
|
|
122
|
+
throw new Error('Set NEXT_PUBLIC_FONTDUE_URL (single-tenant) or FONTDUE_MULTI_TENANT=1 (multi-tenant).');
|
|
123
|
+
}
|
|
124
|
+
const userImages = nextConfig.images ?? {};
|
|
125
|
+
return {
|
|
126
|
+
...nextConfig,
|
|
127
|
+
async rewrites() {
|
|
128
|
+
const user = await resolveRewrites(nextConfig.rewrites);
|
|
129
|
+
return {
|
|
130
|
+
// App rules run first, against the public (pre-tenant) path; because
|
|
131
|
+
// beforeFiles rules chain, a dotless path they produce is then
|
|
132
|
+
// prefixed by the tenant rules like any direct request. afterFiles
|
|
133
|
+
// and fallback rules run after the tenant prefix is applied, so
|
|
134
|
+
// their sources must match the internal /[domain]/... form.
|
|
135
|
+
beforeFiles: [...user.beforeFiles, ...tenantRewrites()],
|
|
136
|
+
afterFiles: user.afterFiles,
|
|
137
|
+
fallback: user.fallback
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
images: {
|
|
141
|
+
// With a Cloudflare image transformation host configured, optimization
|
|
142
|
+
// moves to its edge (image-loader.ts) and the in-process optimizer —
|
|
143
|
+
// and sharp — stay out of the deployment entirely. The host must be in
|
|
144
|
+
// the env at build time (the loader is inlined into the client bundle)
|
|
145
|
+
// as well as at serve time (this config runs again under next start).
|
|
146
|
+
//
|
|
147
|
+
// Without one, in multi-tenant dev the GraphQL origin is typically a
|
|
148
|
+
// container or tunnel from which tenant hostnames don't resolve
|
|
149
|
+
// publicly, so the optimizer can't fetch the originals — serve them
|
|
150
|
+
// unoptimized there.
|
|
151
|
+
...(process.env.NEXT_PUBLIC_FONTDUE_IMAGE_HOST ? {
|
|
152
|
+
loader: 'custom',
|
|
153
|
+
// Next resolves loaderFile against the project root and rejects
|
|
154
|
+
// absolute paths, so point into this package relative to cwd.
|
|
155
|
+
loaderFile: relative(process.cwd(), fileURLToPath(new URL('./image-loader.js', import.meta.url)))
|
|
156
|
+
} : {
|
|
157
|
+
unoptimized: isMultiTenant && process.env.NODE_ENV !== 'production'
|
|
158
|
+
}),
|
|
159
|
+
dangerouslyAllowSVG: true,
|
|
160
|
+
...userImages,
|
|
161
|
+
remotePatterns: [...(fontdueUrl ? [{
|
|
162
|
+
protocol: 'https',
|
|
163
|
+
hostname: new URL(fontdueUrl).hostname
|
|
164
|
+
}] : []), {
|
|
165
|
+
protocol: 'https',
|
|
166
|
+
hostname: '*.fontdue.com'
|
|
167
|
+
},
|
|
168
|
+
// Multi-tenant: logos and images can live on any tenant custom
|
|
169
|
+
// domain. The URLs all come from the Fontdue CMS, not user input.
|
|
170
|
+
...(isMultiTenant ? [{
|
|
171
|
+
protocol: 'https',
|
|
172
|
+
hostname: '**'
|
|
173
|
+
}] : []), ...(userImages.remotePatterns ?? [])]
|
|
174
|
+
},
|
|
175
|
+
// Treat every user agent as HTML-limited so metadata rendering blocks
|
|
176
|
+
// the response. Streamed metadata locks in a 200 status before
|
|
177
|
+
// generateMetadata's notFound() can produce a real 404.
|
|
178
|
+
htmlLimitedBots: nextConfig.htmlLimitedBots ?? /.*/
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Custom next/image loader that serves images through a Cloudflare image
|
|
2
|
+
// transformation host (developers.cloudflare.com/images/transform-images)
|
|
3
|
+
// instead of the in-process Next optimizer, so the deployment needs neither
|
|
4
|
+
// the /_next/image endpoint nor sharp. withFontdue activates it when
|
|
5
|
+
// NEXT_PUBLIC_FONTDUE_IMAGE_HOST is set; see config.ts.
|
|
6
|
+
//
|
|
7
|
+
// NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS (comma-separated hostnames) should
|
|
8
|
+
// mirror the transformation host's allowed-origins list. Sources on other
|
|
9
|
+
// hosts — e.g. the /logo endpoint a site serves from its own (possibly
|
|
10
|
+
// customer-owned) domain, which can't be allowlisted — are served as-is
|
|
11
|
+
// rather than as transform URLs Cloudflare would refuse (ERROR 9401).
|
|
12
|
+
//
|
|
13
|
+
// Next bundles this file into the client build and inlines the NEXT_PUBLIC_
|
|
14
|
+
// reads at build time, so it must stay dependency-free, and the variables
|
|
15
|
+
// have to be present when `next build` runs (not just at serve time).
|
|
16
|
+
|
|
17
|
+
function transformable(src) {
|
|
18
|
+
const origins = process.env.NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS;
|
|
19
|
+
if (!origins) return true;
|
|
20
|
+
let hostname;
|
|
21
|
+
try {
|
|
22
|
+
hostname = new URL(src).hostname;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return origins.split(',').some(origin => origin.trim() === hostname);
|
|
27
|
+
}
|
|
28
|
+
export default function fontdueImageLoader(_ref) {
|
|
29
|
+
let {
|
|
30
|
+
src,
|
|
31
|
+
width,
|
|
32
|
+
quality
|
|
33
|
+
} = _ref;
|
|
34
|
+
const host = process.env.NEXT_PUBLIC_FONTDUE_IMAGE_HOST;
|
|
35
|
+
// No transform host, a local asset it couldn't fetch, or a source outside
|
|
36
|
+
// its allowed origins: serve the original, like `unoptimized`.
|
|
37
|
+
if (!host || src.startsWith('/') || !transformable(src)) return src;
|
|
38
|
+
return `https://${host}/cdn-cgi/image/width=${width},quality=${quality ?? 75},format=auto/${src}`;
|
|
39
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { isMultiTenant, isValidDomain, fontdueEndpoint, fallbackSiteUrl, fontdueServerConfig, configureFontdueRender, type FontdueEndpoint, } from './tenant.js';
|
|
2
|
+
export { setFontdueServerConfig, getFontdueServerConfig, type FontdueServerConfig, } from '../relay/serverConfig.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Server-side entrypoint for Next.js apps (the App Router / RSC adapter).
|
|
2
|
+
// The config-time wrapper lives in 'fontdue-js/next/config' and the deploy
|
|
3
|
+
// hook route handler in 'fontdue-js/next/revalidate'.
|
|
4
|
+
|
|
5
|
+
export { isMultiTenant, isValidDomain, fontdueEndpoint, fallbackSiteUrl, fontdueServerConfig, configureFontdueRender } from './tenant.js';
|
|
6
|
+
|
|
7
|
+
// The per-render config store consumed by fontdue-js's own server-side
|
|
8
|
+
// fetches. configureFontdueRender covers the common case; these are exported
|
|
9
|
+
// for apps that need to set or inspect the config directly.
|
|
10
|
+
export { setFontdueServerConfig, getFontdueServerConfig } from '../relay/serverConfig.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function POST(request: Request): Promise<Response>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Route handler for Fontdue's deploy hook: re-export it from
|
|
2
|
+
// app/api/revalidate/route.ts:
|
|
3
|
+
//
|
|
4
|
+
// export { POST } from 'fontdue-js/next/revalidate';
|
|
5
|
+
//
|
|
6
|
+
// Fontdue calls it when a site's content changes. Multi-tenant deployments
|
|
7
|
+
// receive the tenant in the URL, e.g.
|
|
8
|
+
// POST /api/revalidate?domain=acme.fontdue.com
|
|
9
|
+
// and only that tenant's cache is purged (pages and embed data share the
|
|
10
|
+
// per-domain tag — see fontdueEndpoint/fontdueServerConfig in ./tenant).
|
|
11
|
+
// Single-tenant deployments use the parameterless form and purge everything
|
|
12
|
+
// carrying the 'graphql' tag.
|
|
13
|
+
|
|
14
|
+
import { revalidateTag } from 'next/cache';
|
|
15
|
+
import { isMultiTenant, isValidDomain } from './tenant.js';
|
|
16
|
+
export async function POST(request) {
|
|
17
|
+
var _URL$searchParams$get;
|
|
18
|
+
const domain = (_URL$searchParams$get = new URL(request.url).searchParams.get('domain')) === null || _URL$searchParams$get === void 0 ? void 0 : _URL$searchParams$get.toLowerCase();
|
|
19
|
+
if (isMultiTenant) {
|
|
20
|
+
if (!domain || !isValidDomain(domain)) {
|
|
21
|
+
return Response.json({
|
|
22
|
+
revalidated: false,
|
|
23
|
+
error: 'Missing or invalid ?domain='
|
|
24
|
+
}, {
|
|
25
|
+
status: 400
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
revalidateTag(`graphql:${domain}`);
|
|
29
|
+
} else {
|
|
30
|
+
revalidateTag('graphql');
|
|
31
|
+
}
|
|
32
|
+
return Response.json({
|
|
33
|
+
revalidated: true,
|
|
34
|
+
domain,
|
|
35
|
+
now: Date.now()
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type FontdueServerConfig } from '../relay/serverConfig.js';
|
|
2
|
+
export declare const isMultiTenant: boolean;
|
|
3
|
+
export declare function isValidDomain(domain: string): boolean;
|
|
4
|
+
export interface FontdueEndpoint {
|
|
5
|
+
/** Base URL the app's own GraphQL fetches should target. */
|
|
6
|
+
origin: string;
|
|
7
|
+
/** Headers the Fontdue server needs to resolve the tenant. */
|
|
8
|
+
headers: Record<string, string>;
|
|
9
|
+
/**
|
|
10
|
+
* Cache tags for the app's own fetch calls (pass as `next: { tags }`), so
|
|
11
|
+
* the revalidate handler (see ./revalidate) can purge one site at a time.
|
|
12
|
+
*/
|
|
13
|
+
tags: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function fontdueEndpoint(domain: string): FontdueEndpoint;
|
|
16
|
+
export declare function fallbackSiteUrl(domain: string): string;
|
|
17
|
+
export declare function fontdueServerConfig(domain: string): FontdueServerConfig;
|
|
18
|
+
export declare function configureFontdueRender(domain: string): FontdueEndpoint | null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Tenant/mode resolution for Next.js apps. An app using this adapter runs in
|
|
2
|
+
// one of two modes:
|
|
3
|
+
//
|
|
4
|
+
// - Single-tenant (default): NEXT_PUBLIC_FONTDUE_URL points at one Fontdue
|
|
5
|
+
// site and every request renders that site.
|
|
6
|
+
//
|
|
7
|
+
// - Multi-tenant (FONTDUE_MULTI_TENANT=1): the tenant is derived per request
|
|
8
|
+
// from the (forwarded) Host header, and one deployment serves every
|
|
9
|
+
// tenant. The rewrites installed by withFontdue (see ./config) turn each
|
|
10
|
+
// request into the /[domain]/... route tree so pages are rendered and
|
|
11
|
+
// cached per domain.
|
|
12
|
+
//
|
|
13
|
+
// In multi-tenant mode, GraphQL is fetched from FONTDUE_ORIGIN (the internal
|
|
14
|
+
// Fontdue server, e.g. http://localhost:4000 when running next to it) with
|
|
15
|
+
// the tenant's domain forwarded via X-Forwarded-Host, authenticated by the
|
|
16
|
+
// FONTDUE_PROXY_SECRET shared secret (the Fontdue server refuses to trust
|
|
17
|
+
// X-Forwarded-Host without it). Without FONTDUE_ORIGIN it falls back to
|
|
18
|
+
// fetching the tenant's public URL directly, which is useful for local
|
|
19
|
+
// development against live sites.
|
|
20
|
+
//
|
|
21
|
+
// The fontdue-js components embedded in pages fetch the same way: their
|
|
22
|
+
// server-side preloads read the per-render config set by
|
|
23
|
+
// configureFontdueRender, and in the browser they fetch the relative
|
|
24
|
+
// /graphql on the page's own origin — so multi-tenant mode needs no
|
|
25
|
+
// NEXT_PUBLIC_FONTDUE_URL at all.
|
|
26
|
+
|
|
27
|
+
import { setFontdueServerConfig } from '../relay/serverConfig.js';
|
|
28
|
+
export const isMultiTenant = process.env.FONTDUE_MULTI_TENANT === '1';
|
|
29
|
+
const singleTenantUrl = process.env.NEXT_PUBLIC_FONTDUE_URL;
|
|
30
|
+
const internalOrigin = process.env.FONTDUE_ORIGIN;
|
|
31
|
+
const proxySecret = process.env.FONTDUE_PROXY_SECRET;
|
|
32
|
+
|
|
33
|
+
// Hostnames only: letters/digits/hyphens/dots, no path or port. Anything else
|
|
34
|
+
// is rejected before it can reach the GraphQL fetch or the filesystem cache.
|
|
35
|
+
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
36
|
+
export function isValidDomain(domain) {
|
|
37
|
+
return domain.length <= 253 && DOMAIN_RE.test(domain);
|
|
38
|
+
}
|
|
39
|
+
// Where to fetch GraphQL for a given tenant domain, plus any headers needed
|
|
40
|
+
// for the Fontdue server to resolve that tenant.
|
|
41
|
+
export function fontdueEndpoint(domain) {
|
|
42
|
+
const tags = ['graphql', `graphql:${domain}`];
|
|
43
|
+
if (!isMultiTenant) {
|
|
44
|
+
if (!singleTenantUrl) {
|
|
45
|
+
throw new Error('fontdue-js/next: set NEXT_PUBLIC_FONTDUE_URL (single-tenant) or FONTDUE_MULTI_TENANT=1 (multi-tenant).');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
origin: singleTenantUrl,
|
|
49
|
+
headers: {},
|
|
50
|
+
tags
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (internalOrigin) {
|
|
54
|
+
return {
|
|
55
|
+
origin: internalOrigin,
|
|
56
|
+
headers: {
|
|
57
|
+
'x-forwarded-host': domain,
|
|
58
|
+
...(proxySecret ? {
|
|
59
|
+
'x-fontdue-proxy-secret': proxySecret
|
|
60
|
+
} : {})
|
|
61
|
+
},
|
|
62
|
+
tags
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
origin: `https://${domain}`,
|
|
67
|
+
headers: {},
|
|
68
|
+
tags
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// metadataBase / sitemap fallback when the site URL setting is empty.
|
|
73
|
+
export function fallbackSiteUrl(domain) {
|
|
74
|
+
return isMultiTenant ? `https://${domain}` : 'http://localhost:3000';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Per-render config for fontdue-js's own server-side fetches: same endpoint
|
|
78
|
+
// and headers as the app's fetches, plus the per-domain cache tag so the
|
|
79
|
+
// revalidate handler purges embed data (theme config, type testers, store)
|
|
80
|
+
// along with the pages. ('graphql' itself is added by the network layer.)
|
|
81
|
+
export function fontdueServerConfig(domain) {
|
|
82
|
+
const {
|
|
83
|
+
origin,
|
|
84
|
+
headers
|
|
85
|
+
} = fontdueEndpoint(domain);
|
|
86
|
+
return {
|
|
87
|
+
url: origin,
|
|
88
|
+
headers,
|
|
89
|
+
cacheTags: [`graphql:${domain}`]
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The one call a data helper needs at the top of every page render:
|
|
94
|
+
// validates the request-derived domain (null means "treat as 404"), then
|
|
95
|
+
// points fontdue-js's server-side fetches at the tenant's endpoint for the
|
|
96
|
+
// rest of this render pass.
|
|
97
|
+
//
|
|
98
|
+
// Call it in a data helper every page goes through, not only in the root
|
|
99
|
+
// layout: an App Router soft navigation re-renders just the page segment,
|
|
100
|
+
// and the per-render config store starts empty on every pass.
|
|
101
|
+
export function configureFontdueRender(domain) {
|
|
102
|
+
if (!isValidDomain(domain)) return null;
|
|
103
|
+
setFontdueServerConfig(fontdueServerConfig(domain));
|
|
104
|
+
return fontdueEndpoint(domain);
|
|
105
|
+
}
|
|
@@ -4,7 +4,7 @@ import { getFontdueServerConfig } from './serverConfig.js';
|
|
|
4
4
|
|
|
5
5
|
// `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
|
|
6
6
|
// (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
|
|
7
|
-
const version = "3.0.0-
|
|
7
|
+
const version = "3.0.0-alpha8";
|
|
8
8
|
const IS_SERVER = typeof window === typeof undefined;
|
|
9
9
|
|
|
10
10
|
// Read env from either process.env (Node/Next.js) or import.meta.env (Vite/Astro).
|
package/dist/vite.js
CHANGED
|
@@ -79,7 +79,9 @@ function getFontdueJsSubpaths() {
|
|
|
79
79
|
for (const key of Object.keys(pkg.exports ?? {})) {
|
|
80
80
|
if (!key.startsWith('./')) continue;
|
|
81
81
|
if (key.endsWith('.css')) continue;
|
|
82
|
+
// Build-config and server-only entrypoints never reach a browser bundle.
|
|
82
83
|
if (key === './vite') continue;
|
|
84
|
+
if (key === './next' || key.startsWith('./next/')) continue;
|
|
83
85
|
out.push(`fontdue-js/${key.slice(2)}`);
|
|
84
86
|
}
|
|
85
87
|
return out;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fontdue-js",
|
|
3
|
-
"version": "3.0.0-
|
|
3
|
+
"version": "3.0.0-alpha8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "npm run relay && run-p build-js build-css build-ts",
|
|
@@ -82,7 +82,10 @@
|
|
|
82
82
|
},
|
|
83
83
|
"exports": {
|
|
84
84
|
".": "./dist/index.js",
|
|
85
|
-
"./
|
|
85
|
+
"./next": "./dist/next/index.js",
|
|
86
|
+
"./next/config": "./dist/next/config.js",
|
|
87
|
+
"./next/revalidate": "./dist/next/revalidate.js",
|
|
88
|
+
"./next/image-loader": "./dist/next/image-loader.js",
|
|
86
89
|
"./fontdue.css": "./dist/fontdue.css",
|
|
87
90
|
"./BuyButton": {
|
|
88
91
|
"react-server": "./dist/components/BuyButton/index.server.js",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Minimal declaration so src/next/revalidate.ts type-checks without `next`
|
|
2
|
+
// installed (it's the host app's dependency; this package only ever runs the
|
|
3
|
+
// import inside a Next.js server).
|
|
4
|
+
declare module 'next/cache' {
|
|
5
|
+
export function revalidateTag(tag: string): void;
|
|
6
|
+
}
|