@fastang/core 0.1.0-alpha.0 → 0.1.0-alpha.1
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/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/adapters/vercel-plan.d.ts +1 -1
- package/dist/adapters/vercel-plan.js +2 -7
- package/dist/angular.js +6 -2
- package/dist/commands/build.js +6 -9
- package/dist/config.d.ts +4 -0
- package/dist/config.js +51 -1
- package/dist/render-context.d.ts +9 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +20 -11
- package/dist/static-path.d.ts +1 -0
- package/dist/static-path.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.1
|
|
4
|
+
|
|
5
|
+
- Preserve separate Set-Cookie headers and allow explicit per-dispatch forwarding to HTML responses.
|
|
6
|
+
- Keep inherited credentials out of Angular's default HTTP transfer cache.
|
|
7
|
+
- Treat internal API redirects as HttpClient errors and normalize local dispatch paths.
|
|
8
|
+
- Reject Set-Cookie responses during public prerendering.
|
|
9
|
+
|
|
10
|
+
- Validate prerender and Vercel configuration before changing build output, including plain config exports.
|
|
11
|
+
- Reject unknown options, invalid types, reserved paths and conflicting prerender destinations.
|
|
12
|
+
|
|
3
13
|
## 0.1.0-alpha.0
|
|
4
14
|
|
|
5
15
|
Initial experimental release.
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @fastang/core
|
|
2
2
|
|
|
3
3
|
Experimental FastAng runtime and tooling for Angular + Fastify applications.
|
|
4
|
-
Version 0.1.0-alpha.
|
|
4
|
+
Version 0.1.0-alpha.1 is an experimental alpha. Public APIs may change between alpha releases.
|
|
5
5
|
|
|
6
6
|
The package contains compiled JavaScript and TypeScript declarations. Angular,
|
|
7
7
|
Fastify, Vite and the Angular compiler are explicit peer dependencies so the app
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
+
import { validateVercelOptions } from "../config.js";
|
|
4
5
|
import { discover, discoverPageTree, pageRoutesSource, renderedPageFiles } from "../routes.js";
|
|
5
6
|
export const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
7
|
/** Match an entire route; parameter segments never consume a slash. */
|
|
@@ -15,15 +16,9 @@ function identifier(path) {
|
|
|
15
16
|
return slug + '-' + createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
|
16
17
|
}
|
|
17
18
|
export function planVercelFunctions(pages, apis, prerendered, options = {}) {
|
|
19
|
+
validateVercelOptions(options);
|
|
18
20
|
const mode = options.functions ?? 'shared';
|
|
19
|
-
if (mode !== 'shared' && mode !== 'routes')
|
|
20
|
-
throw new Error('vercel.functions must be shared or routes');
|
|
21
21
|
const prefixes = [...new Set(options.pagePrefixes ?? [''])];
|
|
22
|
-
if (!prefixes.length || prefixes.some((prefix) => prefix !== '' && !/^\/(?:[\w-]+)(?:\/[\w-]+)*$/.test(prefix))) {
|
|
23
|
-
throw new Error('vercel.pagePrefixes must contain empty or absolute static prefixes without a trailing slash');
|
|
24
|
-
}
|
|
25
|
-
if (prefixes.some((prefix) => /^\/(?:api|assets|__fastang)(?:\/|$)/.test(prefix)))
|
|
26
|
-
throw new Error('Reserved Vercel page prefix');
|
|
27
22
|
const functions = [
|
|
28
23
|
{ name: mode === 'routes' ? '__fastang/ssr' : 'ssr', kind: 'ssr', entry: 'vercel', paths: [], sources: [] },
|
|
29
24
|
{ name: mode === 'routes' ? '__fastang/api' : 'api', kind: 'api', entry: 'api', paths: [], sources: [] },
|
package/dist/angular.js
CHANGED
|
@@ -19,8 +19,12 @@ function localApiBackend() {
|
|
|
19
19
|
if (contentType && !request.headers.has('content-type'))
|
|
20
20
|
headers['content-type'] = contentType;
|
|
21
21
|
return from(context.dispatchApi(target.pathname + target.search, request.method, request.body, headers)).pipe(map((response) => {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
let responseHeaders = new HttpHeaders(response.headers);
|
|
23
|
+
// Inherited credentials were invisible to the transfer-cache request check.
|
|
24
|
+
// Mark the response private so Angular's native response check excludes it.
|
|
25
|
+
if (response.credentialed)
|
|
26
|
+
responseHeaders = responseHeaders.set('cache-control', 'private, no-store');
|
|
27
|
+
if (response.status < 200 || response.status >= 300)
|
|
24
28
|
throw new HttpErrorResponse({ status: response.status, error: response.body, headers: responseHeaders, url: request.url });
|
|
25
29
|
return new HttpResponse({ status: response.status, body: response.body, headers: responseHeaders, url: request.url });
|
|
26
30
|
}));
|
package/dist/commands/build.js
CHANGED
|
@@ -13,22 +13,17 @@ import { build } from 'vite';
|
|
|
13
13
|
import { loadConfigFromFile } from 'vite';
|
|
14
14
|
import { prepareVercelPlan } from "../adapters/vercel-plan.js";
|
|
15
15
|
import { buildVercelOutput } from "../adapters/vercel.js";
|
|
16
|
-
import { staticFile } from "../
|
|
16
|
+
import { staticFile } from "../static-path.js";
|
|
17
|
+
import { validateConfig } from "../config.js";
|
|
17
18
|
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, 'fastang.config.ts');
|
|
18
19
|
if (!loaded)
|
|
19
20
|
throw new Error('Missing fastang.config.ts');
|
|
20
|
-
const config = loaded.config;
|
|
21
|
+
const config = validateConfig(loaded.config);
|
|
21
22
|
const vercel = process.argv.includes('--vercel');
|
|
22
|
-
await rm('dist', { recursive: true, force: true });
|
|
23
23
|
const paths = [...new Set(config.prerender)];
|
|
24
|
-
for (const path of paths) {
|
|
25
|
-
staticFile(path);
|
|
26
|
-
if (path === '/' || path === '/api' || path.startsWith('/api/') || path.startsWith('/assets/')) {
|
|
27
|
-
throw new Error(`Percorso riservato al runtime: ${path}`);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
24
|
const vercelPlan = vercel ? await prepareVercelPlan(paths, config.vercel) : undefined;
|
|
31
25
|
const endpointEntries = Object.fromEntries((vercelPlan?.functions ?? []).filter((fn) => fn.entry.startsWith('endpoint-')).map((fn) => [fn.entry, `.fastang/vercel/${fn.entry}.ts`]));
|
|
26
|
+
await rm('dist', { recursive: true, force: true });
|
|
32
27
|
await build({ build: { outDir: 'dist/client' } });
|
|
33
28
|
await build({ build: {
|
|
34
29
|
outDir: 'dist/server', minify: true, ssr: vercel ? true : 'src/server/start.ts',
|
|
@@ -57,6 +52,8 @@ try {
|
|
|
57
52
|
const response = await app.inject({ url: path });
|
|
58
53
|
if (response.statusCode !== 200)
|
|
59
54
|
throw new Error(`Prerender ${path}: HTTP ${response.statusCode}`);
|
|
55
|
+
if (response.headers['set-cookie'])
|
|
56
|
+
throw new Error(`Prerender ${path}: Set-Cookie is not allowed in public HTML`);
|
|
60
57
|
const target = resolve('dist/client', staticFile(path));
|
|
61
58
|
await mkdir(dirname(target), { recursive: true });
|
|
62
59
|
await writeFile(target, response.body);
|
package/dist/config.d.ts
CHANGED
|
@@ -11,3 +11,7 @@ export interface FastangConfig {
|
|
|
11
11
|
vercel?: VercelOptions;
|
|
12
12
|
}
|
|
13
13
|
export declare function defineConfig(config: FastangConfig): FastangConfig;
|
|
14
|
+
/** Also used by the adapter when invoked without defineConfig. */
|
|
15
|
+
export declare function validateVercelOptions(value: unknown): asserts value is VercelOptions;
|
|
16
|
+
/** Validate plain exports too, before the build touches existing output. */
|
|
17
|
+
export declare function validateConfig(value: unknown): FastangConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
import { staticFile } from "./static-path.js";
|
|
1
2
|
export function defineConfig(config) {
|
|
2
|
-
return config;
|
|
3
|
+
return validateConfig(config);
|
|
4
|
+
}
|
|
5
|
+
function object(value, field, keys) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
7
|
+
throw new Error(`${field} must be an object`);
|
|
8
|
+
for (const key of Object.keys(value))
|
|
9
|
+
if (!keys.includes(key))
|
|
10
|
+
throw new Error(`Unknown ${field} option: ${key}`);
|
|
11
|
+
}
|
|
12
|
+
/** Also used by the adapter when invoked without defineConfig. */
|
|
13
|
+
export function validateVercelOptions(value) {
|
|
14
|
+
object(value, 'vercel', ['functions', 'pagePrefixes', 'prunePages']);
|
|
15
|
+
if (value.functions !== undefined && value.functions !== 'shared' && value.functions !== 'routes')
|
|
16
|
+
throw new Error('vercel.functions must be shared or routes');
|
|
17
|
+
if (value.prunePages !== undefined && typeof value.prunePages !== 'boolean')
|
|
18
|
+
throw new Error('vercel.prunePages must be a boolean');
|
|
19
|
+
if (value.pagePrefixes !== undefined) {
|
|
20
|
+
if (!Array.isArray(value.pagePrefixes) || !value.pagePrefixes.length || value.pagePrefixes.some(prefix => typeof prefix !== 'string' || (prefix !== '' && !/^\/(?:[\w-]+)(?:\/[\w-]+)*$/.test(prefix)))) {
|
|
21
|
+
throw new Error('vercel.pagePrefixes must contain empty or absolute static prefixes without a trailing slash');
|
|
22
|
+
}
|
|
23
|
+
if (value.pagePrefixes.some(prefix => /^\/(?:api|assets|__fastang)(?:\/|$)/.test(prefix)))
|
|
24
|
+
throw new Error('Reserved Vercel page prefix');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Validate plain exports too, before the build touches existing output. */
|
|
28
|
+
export function validateConfig(value) {
|
|
29
|
+
object(value, 'FastAng', ['prerender', 'vercel']);
|
|
30
|
+
if (!Array.isArray(value.prerender))
|
|
31
|
+
throw new Error('prerender must be an array of URL paths');
|
|
32
|
+
const targets = new Map();
|
|
33
|
+
for (const [index, path] of value.prerender.entries()) {
|
|
34
|
+
if (typeof path !== 'string')
|
|
35
|
+
throw new Error(`prerender[${index}] must be a URL path string`);
|
|
36
|
+
let target;
|
|
37
|
+
try {
|
|
38
|
+
target = staticFile(path);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error(`Invalid prerender[${index}] path: ${JSON.stringify(path)}`);
|
|
42
|
+
}
|
|
43
|
+
if (path === '/' || /^\/(?:api|assets|__fastang)(?:\/|$)/.test(path))
|
|
44
|
+
throw new Error(`Reserved prerender path: ${path}`);
|
|
45
|
+
const previous = targets.get(target);
|
|
46
|
+
if (previous !== undefined && previous !== path)
|
|
47
|
+
throw new Error(`Conflicting prerender paths: ${previous} and ${path}`);
|
|
48
|
+
targets.set(target, path);
|
|
49
|
+
}
|
|
50
|
+
if (value.vercel !== undefined)
|
|
51
|
+
validateVercelOptions(value.vercel);
|
|
52
|
+
return value;
|
|
3
53
|
}
|
package/dist/render-context.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export interface ApiDispatchResult {
|
|
2
2
|
status: number;
|
|
3
3
|
body: unknown;
|
|
4
|
-
|
|
4
|
+
/** Set-Cookie values remain separate, including Expires commas. */
|
|
5
|
+
headers: Record<string, string | string[]>;
|
|
6
|
+
/** Credentials actually sent to the internal endpoint, including inherited ones. */
|
|
7
|
+
credentialed: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ApiDispatchOptions {
|
|
10
|
+
/** Append internal Set-Cookie headers to this HTML response only. Default: false. */
|
|
11
|
+
forwardSetCookie?: boolean;
|
|
5
12
|
}
|
|
6
13
|
export interface RenderContext {
|
|
7
14
|
status: number;
|
|
@@ -9,5 +16,5 @@ export interface RenderContext {
|
|
|
9
16
|
location: string;
|
|
10
17
|
status?: 301 | 302 | 303 | 307 | 308;
|
|
11
18
|
};
|
|
12
|
-
dispatchApi(path: string, method: string, body: unknown, headers: Record<string, string
|
|
19
|
+
dispatchApi(path: string, method: string, body: unknown, headers: Record<string, string>, options?: ApiDispatchOptions): Promise<ApiDispatchResult>;
|
|
13
20
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { staticFile } from './static-path.ts';
|
|
1
2
|
import { type FastifyInstance, type FastifyServerOptions } from 'fastify';
|
|
2
3
|
import type { RenderContext } from './render-context.ts';
|
|
3
4
|
export type Renderer = (url: string, document: string, context: RenderContext) => Promise<string>;
|
|
@@ -10,6 +11,5 @@ export interface ServerOptions {
|
|
|
10
11
|
prerendered?: string[];
|
|
11
12
|
configure?: (app: FastifyInstance) => Promise<void> | void;
|
|
12
13
|
}
|
|
13
|
-
export declare function staticFile(path: string): string;
|
|
14
14
|
export declare function createApiServer(options?: Pick<ServerOptions, 'logger' | 'configure' | 'registerApi'>): Promise<FastifyInstance>;
|
|
15
15
|
export declare function createServer(options: ServerOptions): Promise<FastifyInstance>;
|
package/dist/server.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
+
import { staticFile } from "./static-path.js";
|
|
2
|
+
export { staticFile } from "./static-path.js";
|
|
1
3
|
import { createHttpServer } from "./http-server.js";
|
|
2
4
|
import {} from 'fastify';
|
|
3
5
|
import { readFile } from 'node:fs/promises';
|
|
4
6
|
import { extname, resolve } from 'node:path';
|
|
5
7
|
import { normalizeRedirectLocation } from "./redirect.js";
|
|
6
|
-
export function staticFile(path) {
|
|
7
|
-
if (!path.startsWith('/') || path.includes('//') || !/^\/[a-zA-Z0-9_/-]*$/.test(path) || path.split('/').includes('..')) {
|
|
8
|
-
throw new Error(`Percorso di prerendering non supportato: ${path}`);
|
|
9
|
-
}
|
|
10
|
-
return `${path.replace(/^\/|\/$/g, '')}${path === '/' ? '' : '/'}index.html`;
|
|
11
|
-
}
|
|
12
8
|
export async function createApiServer(options = {}) {
|
|
13
9
|
return createHttpServer({
|
|
14
10
|
...options,
|
|
@@ -42,9 +38,11 @@ export async function createServer(options) {
|
|
|
42
38
|
const html = await readFile(resolve(options.clientDir, staticPath), 'utf8');
|
|
43
39
|
return reply.header('x-fastang-render', 'prerender').header('cache-control', 'public, max-age=0, must-revalidate').type('text/html; charset=utf-8').send(html);
|
|
44
40
|
}
|
|
41
|
+
const responseCookies = [];
|
|
45
42
|
const context = {
|
|
46
43
|
status: 200,
|
|
47
|
-
async dispatchApi(path, method, body, headers) {
|
|
44
|
+
async dispatchApi(path, method, body, headers, dispatchOptions = {}) {
|
|
45
|
+
path = normalizeRedirectLocation(path);
|
|
48
46
|
if (!path.startsWith('/api/'))
|
|
49
47
|
throw new Error('Solo API locali consentite');
|
|
50
48
|
const forwarded = {};
|
|
@@ -53,18 +51,24 @@ export async function createServer(options) {
|
|
|
53
51
|
if (typeof value === 'string')
|
|
54
52
|
forwarded[name] = value;
|
|
55
53
|
}
|
|
54
|
+
const outgoing = { ...forwarded, ...(body == null ? {} : { 'content-type': 'application/json' }), ...Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) };
|
|
56
55
|
const response = await app.inject({
|
|
57
56
|
method: method, url: path,
|
|
58
|
-
headers:
|
|
57
|
+
headers: outgoing,
|
|
59
58
|
...(body == null ? {} : { payload: typeof body === 'string' ? body : JSON.stringify(body) }),
|
|
60
59
|
});
|
|
61
60
|
const responseHeaders = {};
|
|
62
61
|
for (const [name, value] of Object.entries(response.headers)) {
|
|
63
62
|
if (value !== undefined)
|
|
64
|
-
responseHeaders[name] = Array.isArray(value) ? value
|
|
63
|
+
responseHeaders[name] = Array.isArray(value) ? [...value] : String(value);
|
|
64
|
+
}
|
|
65
|
+
if (dispatchOptions.forwardSetCookie === true) {
|
|
66
|
+
const cookies = responseHeaders['set-cookie'];
|
|
67
|
+
if (cookies)
|
|
68
|
+
responseCookies.push(...(Array.isArray(cookies) ? cookies : [cookies]));
|
|
65
69
|
}
|
|
66
70
|
const contentType = String(response.headers['content-type'] ?? '');
|
|
67
|
-
return { status: response.statusCode, body: contentType.includes('json') ? response.json() : response.body, headers: responseHeaders };
|
|
71
|
+
return { status: response.statusCode, body: contentType.includes('json') ? response.json() : response.body, headers: responseHeaders, credentialed: !!(outgoing['cookie'] || outgoing['authorization']) };
|
|
68
72
|
},
|
|
69
73
|
};
|
|
70
74
|
const html = await options.render(`http://fastang.local${request.url}`, await options.template(request.url), context);
|
|
@@ -72,9 +76,14 @@ export async function createServer(options) {
|
|
|
72
76
|
const status = context.redirect.status ?? 302;
|
|
73
77
|
if (![301, 302, 303, 307, 308].includes(status))
|
|
74
78
|
throw new Error('Stato redirect non valido');
|
|
79
|
+
const location = normalizeRedirectLocation(context.redirect.location);
|
|
80
|
+
if (responseCookies.length)
|
|
81
|
+
reply.header('set-cookie', responseCookies);
|
|
75
82
|
return reply.header('x-fastang-render', 'redirect').header('cache-control', 'private, no-store')
|
|
76
|
-
.redirect(
|
|
83
|
+
.redirect(location, status);
|
|
77
84
|
}
|
|
85
|
+
if (responseCookies.length)
|
|
86
|
+
reply.header('set-cookie', responseCookies);
|
|
78
87
|
return reply.code(context.status).header('x-fastang-render', 'ssr').header('cache-control', 'private, no-store').type('text/html; charset=utf-8').send(html);
|
|
79
88
|
});
|
|
80
89
|
return app;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function staticFile(path: string): string;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function staticFile(path) {
|
|
2
|
+
if (!path.startsWith('/') || path.includes('//') || !/^\/[a-zA-Z0-9_/-]*$/.test(path) || path.split('/').includes('..')) {
|
|
3
|
+
throw new Error(`Percorso di prerendering non supportato: ${path}`);
|
|
4
|
+
}
|
|
5
|
+
return `${path.replace(/^\/|\/$/g, '')}${path === '/' ? '' : '/'}index.html`;
|
|
6
|
+
}
|