@tylix/angular 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -2
- package/package.json +80 -4
- package/src/access.js +36 -0
- package/src/adapter.d.ts +15 -0
- package/src/adapter.js +63 -0
- package/src/index.d.ts +75 -0
- package/src/index.js +284 -0
- package/src/server.d.ts +22 -0
- package/src/server.js +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mbiyrukzz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tylix/angular
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Angular (20 and later) for Tylix apps: signals for the session, API data
|
|
4
|
+
and live channels, functional route guards, an HttpClient interceptor,
|
|
5
|
+
server-side rendering, and the Vite adapter the Tylix CLI loads. Create an
|
|
6
|
+
app with it:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm create tylix@latest my-app -- --frontend angular
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Signals
|
|
13
|
+
|
|
14
|
+
Call these in an injection context (a field initializer):
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { injectAuth, injectApi, injectChannel, injectHydrated } from '@tylix/angular'
|
|
18
|
+
|
|
19
|
+
export class Dashboard {
|
|
20
|
+
protected readonly auth = injectAuth() // auth.user(), auth.isAuthenticated()
|
|
21
|
+
protected readonly posts = injectApi<Post[]>('/api/posts') // posts.data(), posts.loading(), posts.reload()
|
|
22
|
+
protected readonly chat = injectChannel<Message>('chat', (message) => console.log(message))
|
|
23
|
+
protected readonly hydrated = injectHydrated()
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
With SSR, `injectApi` GET requests are loaded on the server and reused in
|
|
28
|
+
the browser.
|
|
29
|
+
|
|
30
|
+
## Guards and HttpClient
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { authGuard, guestGuard, tylixInterceptor } from '@tylix/angular'
|
|
34
|
+
|
|
35
|
+
{ path: 'dashboard', component: Dashboard, canActivate: [authGuard] }
|
|
36
|
+
{ path: 'login', component: Login, canActivate: [guestGuard] }
|
|
37
|
+
|
|
38
|
+
provideHttpClient(withFetch(), withInterceptors([tylixInterceptor]))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The interceptor sends the session cookies and, after a 401, renews the
|
|
42
|
+
session once and retries.
|
|
43
|
+
|
|
44
|
+
## The starter's layout
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
app/frontend/
|
|
48
|
+
app.routes.ts every page and its URL (make:page adds routes here)
|
|
49
|
+
providers/ injectable stores with data and actions (PublicPostsStore,
|
|
50
|
+
MyPostsStore), provided per page component
|
|
51
|
+
services/ the API URLs and calls; only stores use them
|
|
52
|
+
pages/ render only
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,82 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tylix/angular",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Angular frontend for Tylix: auth and API signals, route guards, an HttpClient interceptor, plus the Vite adapter the Tylix CLI loads.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mbiyrukzz",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"tylix",
|
|
10
|
+
"angular",
|
|
11
|
+
"adapter",
|
|
12
|
+
"signals",
|
|
13
|
+
"ssr",
|
|
14
|
+
"vite"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/Mbiyrukzz/tylix/tree/main/packages/angular#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/Mbiyrukzz/tylix.git",
|
|
20
|
+
"directory": "packages/angular"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Mbiyrukzz/tylix/issues"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.13"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"main": "./src/index.js",
|
|
30
|
+
"types": "./src/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./src/index.d.ts",
|
|
34
|
+
"default": "./src/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./adapter": {
|
|
37
|
+
"types": "./src/adapter.d.ts",
|
|
38
|
+
"default": "./src/adapter.js"
|
|
39
|
+
},
|
|
40
|
+
"./server": {
|
|
41
|
+
"types": "./src/server.d.ts",
|
|
42
|
+
"default": "./src/server.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"!**/*.test.js",
|
|
48
|
+
"!**/*.test.mjs",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node --test src/*.test.js"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@tylix/client": "^0.7.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@angular/common": ">=20.0.0",
|
|
63
|
+
"@angular/core": ">=20.0.0",
|
|
64
|
+
"@angular/router": ">=20.0.0",
|
|
65
|
+
"rxjs": "^7.4.0",
|
|
66
|
+
"@angular/platform-server": ">=20.0.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"@angular/platform-server": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@angular/common": "^22.2.0",
|
|
75
|
+
"@angular/compiler": "^22.2.0",
|
|
76
|
+
"@angular/core": "^22.2.0",
|
|
77
|
+
"@angular/platform-browser": "^22.2.0",
|
|
78
|
+
"@angular/platform-server": "^22.2.0",
|
|
79
|
+
"@angular/router": "^22.2.0",
|
|
80
|
+
"rxjs": "^7.8.0"
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/access.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Framework-free rules shared by the guards and the app's session watcher,
|
|
2
|
+
// kept apart so they can be tested without Angular.
|
|
3
|
+
|
|
4
|
+
// Where to go after logging in: the ?next= path, if it stays on this site,
|
|
5
|
+
// else the dashboard. "//evil.com" or "https://evil.com" would leave it.
|
|
6
|
+
export function nextPath(next) {
|
|
7
|
+
if (typeof next === 'string' && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\')) {
|
|
8
|
+
return next
|
|
9
|
+
}
|
|
10
|
+
return '/dashboard'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// What to do with a page of the given access level for a session status:
|
|
14
|
+
// null show it
|
|
15
|
+
// { login: url } send to /login?next=url
|
|
16
|
+
// { to: path } send somewhere else (a logged-in user on a guest page)
|
|
17
|
+
// access is 'auth' (logged-in only), 'guest' (logged-out only) or absent.
|
|
18
|
+
export function decideAccess(access, status, url, next) {
|
|
19
|
+
if (access === 'auth' && status !== 'authenticated') return { login: url }
|
|
20
|
+
if (access === 'guest' && status === 'authenticated') return { to: nextPath(next) }
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AUTH_PATHS = new Set(['/api/login', '/api/register', '/api/auth/refresh', '/api/auth/logout'])
|
|
25
|
+
|
|
26
|
+
// Whether a 401 from `url` means "access token expired, try a refresh".
|
|
27
|
+
// On the auth endpoints a 401 means wrong credentials or no session.
|
|
28
|
+
export function canRefreshAfter401(url) {
|
|
29
|
+
let pathname
|
|
30
|
+
try {
|
|
31
|
+
pathname = new URL(url, 'http://tylix.local').pathname
|
|
32
|
+
} catch {
|
|
33
|
+
pathname = String(url).split('?')[0]
|
|
34
|
+
}
|
|
35
|
+
return !AUTH_PATHS.has(pathname)
|
|
36
|
+
}
|
package/src/adapter.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface FrontendAdapter {
|
|
2
|
+
name: string
|
|
3
|
+
label: string
|
|
4
|
+
root: string
|
|
5
|
+
dedupe: string[]
|
|
6
|
+
requires: string[]
|
|
7
|
+
vitePlugins(context: { importFromProject(specifier: string): Promise<any>; root: string }): Promise<unknown>
|
|
8
|
+
typecheck?: { bin: string; args(tsconfig: string): string[] }
|
|
9
|
+
ssr?: boolean
|
|
10
|
+
ssrFallback?: string
|
|
11
|
+
viteConfig?: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare const adapter: FrontendAdapter
|
|
15
|
+
export default adapter
|
package/src/adapter.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
// What the Tylix CLI needs to host an Angular frontend. Runs in Node,
|
|
4
|
+
// inside `tylix dev`, `tylix build` and `tylix start`, never in the
|
|
5
|
+
// browser. Angular is compiled by AnalogJS's Vite plugin, so it runs in the
|
|
6
|
+
// same Vite host as React, Svelte and Vue (no separate Angular CLI server).
|
|
7
|
+
export default {
|
|
8
|
+
name: 'angular',
|
|
9
|
+
label: 'Angular',
|
|
10
|
+
root: 'app/frontend',
|
|
11
|
+
|
|
12
|
+
// One Angular runtime (core, router, ...) and one session in the bundle.
|
|
13
|
+
dedupe: [
|
|
14
|
+
'@angular/core',
|
|
15
|
+
'@angular/common',
|
|
16
|
+
'@angular/router',
|
|
17
|
+
'@angular/forms',
|
|
18
|
+
'@angular/platform-browser',
|
|
19
|
+
// Imported by @tylix/angular/server: without this, a linked @tylix/angular
|
|
20
|
+
// would load it (and through it a second @angular/core) from its own
|
|
21
|
+
// location, and server rendering fails with NG0201.
|
|
22
|
+
'@angular/platform-server',
|
|
23
|
+
'@angular/compiler',
|
|
24
|
+
'rxjs',
|
|
25
|
+
'@tylix/client',
|
|
26
|
+
'@tylix/angular',
|
|
27
|
+
],
|
|
28
|
+
|
|
29
|
+
requires: ['vite', '@analogjs/vite-plugin-angular', '@angular/build', '@angular/compiler-cli', '@angular/core'],
|
|
30
|
+
|
|
31
|
+
async vitePlugins({ importFromProject, root }) {
|
|
32
|
+
const { default: angular } = await importFromProject('@analogjs/vite-plugin-angular')
|
|
33
|
+
// liveReload: Angular's component HMR, so template and style edits
|
|
34
|
+
// apply in place in `tylix dev` instead of reloading the page.
|
|
35
|
+
return angular({ tsconfig: path.join(root, 'tsconfig.json'), liveReload: true })
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Bundle Angular into the server build too. Its npm packages are only
|
|
39
|
+
// partially compiled; the Vite plugin's linker finishes them when they're
|
|
40
|
+
// bundled. Left external, Node would load them unlinked, and they fail
|
|
41
|
+
// without the JIT compiler. The same goes for any Angular component
|
|
42
|
+
// library, like tylix-icons/angular; add others to frontend.vite in
|
|
43
|
+
// tylix.config (ssr.noExternal).
|
|
44
|
+
viteConfig: {
|
|
45
|
+
ssr: { noExternal: [/^@angular\//, /^tylix-icons\/angular/] },
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Server-side rendering (frontend.ssr: true): app/frontend/entry-server
|
|
49
|
+
// renders pages with renderWithData from @tylix/angular/server.
|
|
50
|
+
ssr: true,
|
|
51
|
+
// Angular's server output includes <app-root> itself, so its outlet
|
|
52
|
+
// replaces that element in index.html; this puts it back when a page is
|
|
53
|
+
// not server-rendered.
|
|
54
|
+
ssrFallback: '<app-root></app-root>',
|
|
55
|
+
|
|
56
|
+
// `tylix build`: ngc checks templates too, which plain tsc can't.
|
|
57
|
+
// --noEmit here, not in the tsconfig: the Vite plugin compiles through
|
|
58
|
+
// that same tsconfig, and noEmit there makes it produce an empty bundle.
|
|
59
|
+
typecheck: {
|
|
60
|
+
bin: 'ngc',
|
|
61
|
+
args: (tsconfig) => ['-p', tsconfig, '--noEmit'],
|
|
62
|
+
},
|
|
63
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { InjectionToken, Signal } from '@angular/core'
|
|
2
|
+
import type { CanActivateFn } from '@angular/router'
|
|
3
|
+
import type { HttpInterceptorFn } from '@angular/common/http'
|
|
4
|
+
import type { ApiOptions, ApiResult, Auth, TylixUser } from '@tylix/client'
|
|
5
|
+
|
|
6
|
+
export { api, auth } from '@tylix/client'
|
|
7
|
+
export type { ApiOptions, ApiResult, TylixUser } from '@tylix/client'
|
|
8
|
+
|
|
9
|
+
export interface InjectedAuth {
|
|
10
|
+
status: Signal<'loading' | 'authenticated' | 'guest'>
|
|
11
|
+
user: Signal<TylixUser | null>
|
|
12
|
+
/** True after a session ended because it could not be refreshed. */
|
|
13
|
+
expired: Signal<boolean>
|
|
14
|
+
loading: Signal<boolean>
|
|
15
|
+
isAuthenticated: Signal<boolean>
|
|
16
|
+
login: Auth['login']
|
|
17
|
+
register: Auth['register']
|
|
18
|
+
logout: Auth['logout']
|
|
19
|
+
reload: Auth['reload']
|
|
20
|
+
forgotPassword: Auth['forgotPassword']
|
|
21
|
+
resetPassword: Auth['resetPassword']
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function injectAuth(): InjectedAuth
|
|
25
|
+
|
|
26
|
+
/** Resolves once the first session check is done. */
|
|
27
|
+
export function sessionReady(): Promise<void>
|
|
28
|
+
|
|
29
|
+
export interface InjectedApi<T> {
|
|
30
|
+
data: Signal<T | null>
|
|
31
|
+
error: Signal<string | null>
|
|
32
|
+
errors: Signal<Record<string, string[]> | null>
|
|
33
|
+
status: Signal<number | null>
|
|
34
|
+
ok: Signal<boolean>
|
|
35
|
+
loading: Signal<boolean>
|
|
36
|
+
reload(): Promise<ApiResult<T> | null>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Call in an injection context. `url` may read signals; null skips fetching. */
|
|
40
|
+
export function injectApi<T = unknown>(url: string | null | (() => string | null), options?: ApiOptions): InjectedApi<T>
|
|
41
|
+
|
|
42
|
+
export const authGuard: CanActivateFn
|
|
43
|
+
export const guestGuard: CanActivateFn
|
|
44
|
+
export const tylixInterceptor: HttpInterceptorFn
|
|
45
|
+
|
|
46
|
+
/** Same-site ?next= path, else '/dashboard'. */
|
|
47
|
+
export function nextPath(next: unknown): string
|
|
48
|
+
|
|
49
|
+
export type AccessDecision = null | { login: string } | { to: string }
|
|
50
|
+
export function decideAccess(
|
|
51
|
+
access: 'auth' | 'guest' | undefined,
|
|
52
|
+
status: 'loading' | 'authenticated' | 'guest',
|
|
53
|
+
url: string,
|
|
54
|
+
next?: unknown,
|
|
55
|
+
): AccessDecision
|
|
56
|
+
|
|
57
|
+
/** What a component can change about the server response during SSR. */
|
|
58
|
+
export interface ServerRenderContext {
|
|
59
|
+
status: number
|
|
60
|
+
redirect: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const TYLIX_SERVER: InjectionToken<ServerRenderContext>
|
|
64
|
+
|
|
65
|
+
/** The server render's context during SSR; null in the browser. */
|
|
66
|
+
export function useServerContext(): ServerRenderContext | null
|
|
67
|
+
|
|
68
|
+
/** A live channel: connects after the first browser render, reconnects, closes on destroy. Injection context. */
|
|
69
|
+
export function injectChannel<T = unknown>(
|
|
70
|
+
name: string,
|
|
71
|
+
onMessage?: (data: T) => void,
|
|
72
|
+
): { send(data: unknown): void; status: Signal<import('@tylix/client').ChannelStatus> }
|
|
73
|
+
|
|
74
|
+
/** False on the server and during hydration, true after the first browser render. Injection context. */
|
|
75
|
+
export function injectHydrated(): Signal<boolean>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Angular bindings for the Tylix client: signals, functional guards and an
|
|
2
|
+
// HttpClient interceptor. Plain JavaScript with no decorators, so it needs
|
|
3
|
+
// no Angular compilation. The session logic itself (refresh, cross-tab
|
|
4
|
+
// sync) lives in @tylix/client, so every frontend behaves the same.
|
|
5
|
+
import {
|
|
6
|
+
DestroyRef,
|
|
7
|
+
InjectionToken,
|
|
8
|
+
PendingTasks,
|
|
9
|
+
afterNextRender,
|
|
10
|
+
computed,
|
|
11
|
+
effect,
|
|
12
|
+
inject,
|
|
13
|
+
signal,
|
|
14
|
+
untracked,
|
|
15
|
+
} from '@angular/core'
|
|
16
|
+
import { Router } from '@angular/router'
|
|
17
|
+
import { HttpErrorResponse } from '@angular/common/http'
|
|
18
|
+
import { catchError, from, switchMap, throwError } from 'rxjs'
|
|
19
|
+
import { api, auth, lazyChannel, refreshSession, resultFromResponse } from '@tylix/client'
|
|
20
|
+
import { canRefreshAfter401, decideAccess, nextPath } from './access.js'
|
|
21
|
+
|
|
22
|
+
export { api, auth, nextPath, decideAccess }
|
|
23
|
+
|
|
24
|
+
// Provided only while a page renders on the server (see ./server.js): that
|
|
25
|
+
// request's session and loader, the data loaded so far, and what the render
|
|
26
|
+
// decided about the response (status, redirect).
|
|
27
|
+
export const TYLIX_SERVER = new InjectionToken('tylix:server')
|
|
28
|
+
|
|
29
|
+
// The server render's context while rendering on the server; null in the
|
|
30
|
+
// browser or outside an injection context.
|
|
31
|
+
// const server = useServerContext()
|
|
32
|
+
// if (server) server.status = 404
|
|
33
|
+
export function useServerContext() {
|
|
34
|
+
try {
|
|
35
|
+
return inject(TYLIX_SERVER, { optional: true })
|
|
36
|
+
} catch {
|
|
37
|
+
return null // not in an injection context
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One signal mirror of the session, shared by every injectAuth() call in
|
|
42
|
+
// the browser. Never used on the server, where state is per request.
|
|
43
|
+
const sessionState = signal({ status: 'loading', user: null })
|
|
44
|
+
let mirrored = false
|
|
45
|
+
|
|
46
|
+
function mirrorSession() {
|
|
47
|
+
if (!mirrored) {
|
|
48
|
+
mirrored = true
|
|
49
|
+
sessionState.set(auth.getState())
|
|
50
|
+
auth.subscribe((state) => sessionState.set(state))
|
|
51
|
+
}
|
|
52
|
+
return auth.init()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolves once the first session check is done.
|
|
56
|
+
export function sessionReady() {
|
|
57
|
+
return mirrorSession()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sessionSignals(state) {
|
|
61
|
+
return {
|
|
62
|
+
status: computed(() => state().status),
|
|
63
|
+
user: computed(() => state().user),
|
|
64
|
+
expired: computed(() => Boolean(state().expired)),
|
|
65
|
+
loading: computed(() => state().status === 'loading'),
|
|
66
|
+
isAuthenticated: computed(() => state().status === 'authenticated'),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const browserSession = sessionSignals(sessionState)
|
|
71
|
+
|
|
72
|
+
// The current session as signals, plus the actions that change it. On the
|
|
73
|
+
// server (in an injection context) it is that request's session; in the
|
|
74
|
+
// browser the shared one, which main.ts initialised from the session the
|
|
75
|
+
// server put in the page, so hydration sees the same state.
|
|
76
|
+
export function injectAuth() {
|
|
77
|
+
const server = useServerContext()
|
|
78
|
+
if (!server) mirrorSession()
|
|
79
|
+
const signals = server ? sessionSignals(signal(server.authState)) : browserSession
|
|
80
|
+
return {
|
|
81
|
+
...signals,
|
|
82
|
+
login: auth.login,
|
|
83
|
+
register: auth.register,
|
|
84
|
+
logout: auth.logout,
|
|
85
|
+
reload: auth.reload,
|
|
86
|
+
forgotPassword: auth.forgotPassword,
|
|
87
|
+
resetPassword: auth.resetPassword,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// False on the server and while a server-rendered page is hydrating; true
|
|
92
|
+
// after the first render in the browser. Before that, a form's submit
|
|
93
|
+
// button would do a native submit and reload the page, losing what was
|
|
94
|
+
// typed, so starter forms keep their buttons disabled until this is true.
|
|
95
|
+
// Call in an injection context.
|
|
96
|
+
export function injectHydrated() {
|
|
97
|
+
const hydrated = signal(false)
|
|
98
|
+
afterNextRender(() => hydrated.set(true))
|
|
99
|
+
return hydrated.asReadonly()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A live channel on the channel server ("tylix channels:work"). Call in an
|
|
103
|
+
// injection context (a field initializer):
|
|
104
|
+
// chat = injectChannel<Message>('chat', (message) => ...)
|
|
105
|
+
// this.chat.send({ text }) and chat.status() in templates
|
|
106
|
+
// Connects after the first render in the browser (never on the server),
|
|
107
|
+
// reconnects on its own, and closes with the component.
|
|
108
|
+
export function injectChannel(name, onMessage) {
|
|
109
|
+
const status = signal('connecting')
|
|
110
|
+
const live = lazyChannel(name, { onMessage: (data) => onMessage?.(data), onStatus: (next) => status.set(next) })
|
|
111
|
+
afterNextRender(() => live.open())
|
|
112
|
+
inject(DestroyRef).onDestroy(() => live.close())
|
|
113
|
+
return { send: (data) => live.send(data), status: status.asReadonly() }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Data a server render loaded for `url`, embedded in the page.
|
|
117
|
+
function serverRenderedData(url) {
|
|
118
|
+
const data = globalThis.__TYLIX_DATA__
|
|
119
|
+
return url != null && data && Object.hasOwn(data, url) ? data[url] : undefined
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isGet(options) {
|
|
123
|
+
return !options?.method || String(options.method).toUpperCase() === 'GET'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fetches `url` now and again whenever it changes. `url` is a string or a
|
|
127
|
+
// function of signals (() => `/api/posts/${this.id()}`); null skips. Call
|
|
128
|
+
// it in an injection context (a field initializer or constructor): the
|
|
129
|
+
// request is cancelled when the component is destroyed.
|
|
130
|
+
//
|
|
131
|
+
// posts = injectApi<Post[]>('/api/posts')
|
|
132
|
+
// @for (post of posts.data() ?? []; track post.id) { ... }
|
|
133
|
+
//
|
|
134
|
+
// With SSR, GET requests are loaded on the server as pending tasks, which
|
|
135
|
+
// Angular waits for before producing the HTML; the page arrives with their
|
|
136
|
+
// data and the browser reuses it instead of fetching again.
|
|
137
|
+
export function injectApi(url, options) {
|
|
138
|
+
const data = signal(null)
|
|
139
|
+
const error = signal(null)
|
|
140
|
+
const errors = signal(null)
|
|
141
|
+
const status = signal(null)
|
|
142
|
+
const ok = signal(false)
|
|
143
|
+
const loading = signal(false)
|
|
144
|
+
|
|
145
|
+
const apply = (result) => {
|
|
146
|
+
data.set(result.data)
|
|
147
|
+
error.set(result.error)
|
|
148
|
+
errors.set(result.errors)
|
|
149
|
+
status.set(result.status)
|
|
150
|
+
ok.set(result.ok)
|
|
151
|
+
loading.set(false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const urlOf = typeof url === 'function' ? url : () => url
|
|
155
|
+
const server = useServerContext()
|
|
156
|
+
const pendingTasks = server ? inject(PendingTasks) : null
|
|
157
|
+
|
|
158
|
+
let controller = null
|
|
159
|
+
let runId = 0
|
|
160
|
+
|
|
161
|
+
async function load(current) {
|
|
162
|
+
if (current == null) {
|
|
163
|
+
loading.set(false)
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
const id = ++runId
|
|
167
|
+
|
|
168
|
+
if (server) {
|
|
169
|
+
if (!isGet(options)) return null
|
|
170
|
+
// A pending task: Angular waits for it before producing the HTML.
|
|
171
|
+
const done = pendingTasks.add()
|
|
172
|
+
try {
|
|
173
|
+
loading.set(true)
|
|
174
|
+
const { status: code, data: body } = await server.load(current)
|
|
175
|
+
const result = resultFromResponse(code, body)
|
|
176
|
+
server.data[current] = result
|
|
177
|
+
if (id === runId) apply(result)
|
|
178
|
+
return result
|
|
179
|
+
} finally {
|
|
180
|
+
done()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
controller?.abort()
|
|
185
|
+
controller = new AbortController()
|
|
186
|
+
loading.set(true)
|
|
187
|
+
const response = await api(current, { ...options, signal: controller.signal })
|
|
188
|
+
if (id === runId && !response.aborted) apply(response)
|
|
189
|
+
return response
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The url whose data came with the server-rendered page: shown at once,
|
|
193
|
+
// used instead of the first fetch, then dropped so a later mount fetches.
|
|
194
|
+
let serverRenderedUrl = null
|
|
195
|
+
if (!server && typeof url !== 'function') {
|
|
196
|
+
const preloaded = serverRenderedData(url)
|
|
197
|
+
if (preloaded) {
|
|
198
|
+
apply(preloaded)
|
|
199
|
+
serverRenderedUrl = url
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Re-runs when a signal read by urlOf changes. The request itself runs
|
|
204
|
+
// untracked, so signals it sets don't loop back into this effect.
|
|
205
|
+
effect(() => {
|
|
206
|
+
const current = urlOf()
|
|
207
|
+
untracked(() => {
|
|
208
|
+
if (!server && current != null && serverRenderedUrl === null) {
|
|
209
|
+
// A url built from signals (e.g. a route input) is only known here.
|
|
210
|
+
const preloaded = serverRenderedData(current)
|
|
211
|
+
if (preloaded && data() === null) {
|
|
212
|
+
apply(preloaded)
|
|
213
|
+
serverRenderedUrl = current
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (current != null && current === serverRenderedUrl) {
|
|
217
|
+
if (globalThis.__TYLIX_DATA__) delete globalThis.__TYLIX_DATA__[current]
|
|
218
|
+
serverRenderedUrl = undefined
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
load(current)
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
inject(DestroyRef).onDestroy(() => {
|
|
226
|
+
runId++
|
|
227
|
+
controller?.abort()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
data: data.asReadonly(),
|
|
232
|
+
error: error.asReadonly(),
|
|
233
|
+
errors: errors.asReadonly(),
|
|
234
|
+
status: status.asReadonly(),
|
|
235
|
+
ok: ok.asReadonly(),
|
|
236
|
+
loading: loading.asReadonly(),
|
|
237
|
+
reload: () => load(untracked(urlOf)),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function guard(access) {
|
|
242
|
+
return async (route, state) => {
|
|
243
|
+
const router = inject(Router)
|
|
244
|
+
const server = useServerContext()
|
|
245
|
+
const status = server ? server.authState.status : (await sessionReady(), auth.getState().status)
|
|
246
|
+
const decision = decideAccess(access, status, state.url, route.queryParamMap.get('next'))
|
|
247
|
+
if (!decision) return true
|
|
248
|
+
|
|
249
|
+
const target = decision.login ? `/login?next=${encodeURIComponent(decision.login)}` : decision.to
|
|
250
|
+
if (server) {
|
|
251
|
+
// Answered with a 302 by the Tylix host; nothing is rendered.
|
|
252
|
+
server.redirect = target
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
return router.parseUrl(target)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// canActivate: [authGuard] for pages that need a logged-in user. Waits for
|
|
260
|
+
// the first session check, then sends guests to /login?next=<this page>
|
|
261
|
+
// (a 302 when rendering on the server). Hides pages only; the API's
|
|
262
|
+
// requireAuth protects the data.
|
|
263
|
+
export const authGuard = guard('auth')
|
|
264
|
+
|
|
265
|
+
// canActivate: [guestGuard] for login/register: a logged-in user goes to
|
|
266
|
+
// ?next= or the dashboard.
|
|
267
|
+
export const guestGuard = guard('guest')
|
|
268
|
+
|
|
269
|
+
// For apps that use HttpClient: sends the session cookies, and on a 401
|
|
270
|
+
// renews the session once (shared with api()) and retries the request.
|
|
271
|
+
//
|
|
272
|
+
// provideHttpClient(withFetch(), withInterceptors([tylixInterceptor]))
|
|
273
|
+
export function tylixInterceptor(request, next) {
|
|
274
|
+
const withCookies = request.clone({ withCredentials: true })
|
|
275
|
+
return next(withCookies).pipe(
|
|
276
|
+
catchError((err) => {
|
|
277
|
+
const retryable = err instanceof HttpErrorResponse && err.status === 401 && canRefreshAfter401(request.url)
|
|
278
|
+
if (!retryable) return throwError(() => err)
|
|
279
|
+
return from(refreshSession()).pipe(
|
|
280
|
+
switchMap((renewed) => (renewed ? next(withCookies) : throwError(() => err))),
|
|
281
|
+
)
|
|
282
|
+
}),
|
|
283
|
+
)
|
|
284
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ApplicationRef } from '@angular/core'
|
|
2
|
+
import type { BootstrapContext } from '@angular/platform-browser'
|
|
3
|
+
import type { ApiResult, TylixUser } from '@tylix/client'
|
|
4
|
+
|
|
5
|
+
export interface RenderOptions {
|
|
6
|
+
url?: string
|
|
7
|
+
session?: { user: TylixUser | null } | null
|
|
8
|
+
load?: (url: string) => Promise<{ status: number; data: unknown }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RenderResult {
|
|
12
|
+
html: string
|
|
13
|
+
head: string
|
|
14
|
+
status: number
|
|
15
|
+
redirect: string | null
|
|
16
|
+
data: Record<string, ApiResult>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderWithData(
|
|
20
|
+
bootstrap: (context: BootstrapContext) => Promise<ApplicationRef>,
|
|
21
|
+
options?: RenderOptions,
|
|
22
|
+
): Promise<RenderResult>
|
package/src/server.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Server-side rendering for Tylix Angular apps. Imported only by the app's
|
|
2
|
+
// entry-server file, so @angular/platform-server never reaches the browser.
|
|
3
|
+
import { renderApplication } from '@angular/platform-server'
|
|
4
|
+
import { TYLIX_SERVER } from './index.js'
|
|
5
|
+
|
|
6
|
+
// Angular skips browser-only work (event replay, which touches `window`)
|
|
7
|
+
// when ngServerMode is set. The Angular CLI defines it for server bundles;
|
|
8
|
+
// Vite doesn't, so it is set here. This module only ever runs in the
|
|
9
|
+
// server process, never in a browser.
|
|
10
|
+
globalThis.ngServerMode = true
|
|
11
|
+
|
|
12
|
+
// Angular renders a whole document. This minimal one only hosts the app;
|
|
13
|
+
// the head and body it produces go into the app's own index.html.
|
|
14
|
+
const DOCUMENT = '<!doctype html><html><head></head><body><app-root></app-root></body></html>'
|
|
15
|
+
|
|
16
|
+
function between(html, open, close) {
|
|
17
|
+
const start = html.search(open)
|
|
18
|
+
if (start === -1) return ''
|
|
19
|
+
const from = html.indexOf('>', start) + 1
|
|
20
|
+
const end = html.lastIndexOf(close)
|
|
21
|
+
return end > from ? html.slice(from, end) : ''
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The per-request context, provided to the app as TYLIX_SERVER.
|
|
25
|
+
export function createServerContext({ session = null, load } = {}) {
|
|
26
|
+
return {
|
|
27
|
+
authState: session?.user
|
|
28
|
+
? { status: 'authenticated', user: session.user }
|
|
29
|
+
: { status: 'guest', user: null },
|
|
30
|
+
load: load ?? (async () => ({ status: 404, data: { error: 'Not found' } })),
|
|
31
|
+
data: {},
|
|
32
|
+
status: 200,
|
|
33
|
+
redirect: null,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Renders the app for one request. `bootstrap` is the app's server
|
|
38
|
+
// bootstrap: (context) => bootstrapApplication(App, config, context).
|
|
39
|
+
// Every injectApi GET is loaded as a pending task, which Angular waits for,
|
|
40
|
+
// so one pass renders the page with its data.
|
|
41
|
+
//
|
|
42
|
+
// Resolves { html, head, status, redirect, data } for the Tylix host: html
|
|
43
|
+
// is the <app-root> element plus Angular's hydration state script, head is
|
|
44
|
+
// what Angular put in <head> (component styles), and data is embedded in
|
|
45
|
+
// the page so the browser's first render reuses it.
|
|
46
|
+
export async function renderWithData(bootstrap, { url = '/', session = null, load } = {}) {
|
|
47
|
+
const context = createServerContext({ session, load })
|
|
48
|
+
const document = await renderApplication(bootstrap, {
|
|
49
|
+
document: DOCUMENT,
|
|
50
|
+
url,
|
|
51
|
+
platformProviders: [{ provide: TYLIX_SERVER, useValue: context }],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (context.redirect) return { html: '', head: '', status: 302, redirect: context.redirect, data: {} }
|
|
55
|
+
return {
|
|
56
|
+
html: between(document, /<body[\s>]/i, '</body>'),
|
|
57
|
+
head: between(document, /<head[\s>]/i, '</head>'),
|
|
58
|
+
status: context.status,
|
|
59
|
+
redirect: null,
|
|
60
|
+
data: context.data,
|
|
61
|
+
}
|
|
62
|
+
}
|