@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 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
- # Temporary Holding Version
1
+ # @tylix/angular
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }
@@ -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
+ }