@aerogel/plugin-solid 0.0.1-next.13b88f634a31caf3669d61e53e7d3fa95e20488c

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/src/main.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { bootServices } from '@aerogel/core';
2
+ import { bootSolidModels } from 'soukai-solid';
3
+ import type { Plugin } from '@aerogel/core';
4
+
5
+ import Solid from '@/services/Solid';
6
+ import { DEFAULT_STATE } from '@/services/Solid.state';
7
+ import {
8
+ authenticators as baseAuthenticators,
9
+ getAuthenticator,
10
+ registerAuthenticators,
11
+ setDefaultAuthenticator,
12
+ } from '@/auth';
13
+ import type Authenticator from '@/auth/Authenticator';
14
+ import type { AuthenticatorName } from '@/auth';
15
+
16
+ const services = { $solid: Solid };
17
+
18
+ export * from './auth';
19
+ export * from './components';
20
+ export * from './errors';
21
+ export * from './services/Solid';
22
+ export { Solid };
23
+
24
+ export interface Options {
25
+ autoReconnect?: boolean;
26
+ authenticators?: Record<string, Authenticator>;
27
+ defaultAuthenticator?: AuthenticatorName;
28
+ }
29
+
30
+ export type SolidServices = typeof services;
31
+
32
+ export default function solid(options: Options = {}): Plugin {
33
+ return {
34
+ async install(app) {
35
+ bootSolidModels();
36
+ registerAuthenticators({ ...baseAuthenticators, ...(options.authenticators ?? {}) });
37
+ setDefaultAuthenticator(getAuthenticator(options.defaultAuthenticator ?? 'inrupt'));
38
+
39
+ if (typeof options.autoReconnect === 'boolean') {
40
+ DEFAULT_STATE.autoReconnect = options.autoReconnect;
41
+
42
+ Solid.hasPersistedState() || (Solid.autoReconnect = options.autoReconnect);
43
+ }
44
+
45
+ await bootServices(app, services);
46
+ },
47
+ };
48
+ }
49
+
50
+ declare module '@vue/runtime-core' {
51
+ interface ComponentCustomProperties extends SolidServices {}
52
+ }
@@ -0,0 +1,56 @@
1
+ import { defineServiceState } from '@aerogel/core';
2
+ import type { ErrorSource } from '@aerogel/core';
3
+ import type { Fetch, SolidUserProfile } from '@noeldemartin/solid-utils';
4
+
5
+ import type { AuthSession } from '@/auth/Authenticator';
6
+ import type { AuthenticatorName } from '@/auth';
7
+
8
+ export const DEFAULT_STATE = { autoReconnect: true };
9
+
10
+ export default defineServiceState({
11
+ name: 'solid',
12
+ persist: ['autoReconnect', 'dismissed', 'previousSession', 'profiles', 'staleProfiles'],
13
+ initialState: {
14
+ autoReconnect: DEFAULT_STATE.autoReconnect,
15
+ dismissed: false,
16
+ ignorePreviousSessionError: false,
17
+ loginStartupError: null as ErrorSource | null,
18
+ loginOngoing: false,
19
+ preferredAuthenticator: null as AuthenticatorName | null,
20
+ previousSession: null as {
21
+ profile?: SolidUserProfile;
22
+ authenticator: AuthenticatorName;
23
+ loginUrl: string;
24
+ error: ErrorSource | null;
25
+ } | null,
26
+ profiles: {} as Record<string, SolidUserProfile>,
27
+ session: null as AuthSession | null,
28
+ loginStale: false,
29
+ staleProfiles: [] as string[],
30
+ },
31
+ serialize(state) {
32
+ if (state.previousSession?.error instanceof Error) {
33
+ state.previousSession.error = state.previousSession.error.message;
34
+ }
35
+
36
+ return state;
37
+ },
38
+ computed: {
39
+ user: (state) => state.session?.user ?? state.previousSession?.profile ?? null,
40
+ authenticator: (state) => state.session?.authenticator ?? null,
41
+ fetch(): Fetch {
42
+ return this.authenticator?.getAuthenticatedFetch() ?? globalThis.fetch.bind(globalThis);
43
+ },
44
+ error(state): ErrorSource | null {
45
+ if (state.loginStartupError) {
46
+ return state.loginStartupError ?? null;
47
+ }
48
+
49
+ if (state.ignorePreviousSessionError) {
50
+ return null;
51
+ }
52
+
53
+ return state.previousSession?.error ?? null;
54
+ },
55
+ },
56
+ });
@@ -0,0 +1,460 @@
1
+ import {
2
+ after,
3
+ arrayFilter,
4
+ arrayUnique,
5
+ arrayWithout,
6
+ asyncMemo,
7
+ facade,
8
+ fail,
9
+ getLocationQueryParameter,
10
+ hasLocationQueryParameter,
11
+ objectWithout,
12
+ objectWithoutEmpty,
13
+ parseBoolean,
14
+ requireUrlParse,
15
+ setAsyncMemo,
16
+ tap,
17
+ urlRoot,
18
+ } from '@noeldemartin/utils';
19
+ import { App, Colors, Errors, Events, UI, translateWithDefault } from '@aerogel/core';
20
+ import { fetchLoginUserProfile } from '@noeldemartin/solid-utils';
21
+ import { setEngine } from 'soukai';
22
+ import { SolidACLAuthorization, SolidContainer, SolidDocument, SolidTypeIndex } from 'soukai-solid';
23
+ import type { ErrorSource } from '@aerogel/core';
24
+ import type { Fetch, SolidModelConstructor } from 'soukai-solid';
25
+ import type { SolidUserProfile } from '@noeldemartin/solid-utils';
26
+
27
+ import AuthenticationCancelledError from '@/errors/AuthenticationCancelledError';
28
+ import { ContainerAlreadyInUse } from '@/errors';
29
+ import { getAuthenticator } from '@/auth';
30
+ import type Authenticator from '@/auth/Authenticator';
31
+ import type { AuthenticatorName } from '@/auth';
32
+ import type { AuthSession } from '@/auth/Authenticator';
33
+
34
+ import Service, { DEFAULT_STATE } from './Solid.state';
35
+
36
+ export type LoginOptions = {
37
+ authenticator?: AuthenticatorName;
38
+ onError?(error: ErrorSource): unknown;
39
+ fallbackUrl?: string;
40
+ };
41
+
42
+ export type ReconnectOptions = Omit<LoginOptions, 'authenticator'> & {
43
+ force?: boolean;
44
+ };
45
+
46
+ export class SolidService extends Service {
47
+
48
+ public isLoggedIn(): this is { session: AuthSession; user: SolidUserProfile; authenticator: Authenticator } {
49
+ return !!this.session;
50
+ }
51
+
52
+ public wasLoggedIn(): this is { user: SolidUserProfile } {
53
+ return !!this.previousSession;
54
+ }
55
+
56
+ public hasLoggedIn(): this is { user: SolidUserProfile } {
57
+ return !!(this.session || this.previousSession);
58
+ }
59
+
60
+ public requireAuthenticator(): Authenticator {
61
+ return this.authenticator ?? fail('Could not get authenticator');
62
+ }
63
+
64
+ public requireUser(): SolidUserProfile {
65
+ return this.user ?? fail('Could not get user profile (are you logged in?)');
66
+ }
67
+
68
+ public requireUserProfile(url: string): Promise<SolidUserProfile> {
69
+ return this.getUserProfile(url, true);
70
+ }
71
+
72
+ public async getUserProfile(url: string, required: true): Promise<SolidUserProfile>;
73
+ public async getUserProfile(url: string, required?: false): Promise<SolidUserProfile | null>;
74
+ public async getUserProfile(url: string, required: boolean = false): Promise<SolidUserProfile | null> {
75
+ return (
76
+ this.profiles[url] ??
77
+ tap(
78
+ await fetchLoginUserProfile(url, {
79
+ fetch: this.fetch,
80
+ required,
81
+ }),
82
+ (profile) => profile && this.rememberProfile(profile),
83
+ )
84
+ );
85
+ }
86
+
87
+ public async refreshUserProfile(): Promise<void> {
88
+ if (!this.isLoggedIn()) {
89
+ return;
90
+ }
91
+
92
+ this.setState({ profiles: objectWithout(this.profiles, [this.user.webId]) });
93
+
94
+ const user = await this.requireUserProfile(this.user.webId);
95
+
96
+ this.setState({
97
+ session: {
98
+ ...this.session,
99
+ user,
100
+ },
101
+ });
102
+ }
103
+
104
+ public async login(loginUrl: string, options: LoginOptions = {}): Promise<boolean> {
105
+ const authenticatorName = options.authenticator ?? this.preferredAuthenticator ?? 'default';
106
+ const handleLoginError =
107
+ options.onError ??
108
+ ((error: ErrorSource) =>
109
+ App.isMounted() ? Errors.report(error) : this.setState({ loginStartupError: error }));
110
+
111
+ if (App.development && loginUrl === 'devserver') {
112
+ loginUrl = 'http://localhost:3000';
113
+ }
114
+
115
+ if (!/^https?:\/\//.test(loginUrl)) {
116
+ options.fallbackUrl ??= `http://${loginUrl}`;
117
+ loginUrl = `https://${loginUrl}`;
118
+ }
119
+
120
+ if (this.isLoggedIn()) {
121
+ return true;
122
+ }
123
+
124
+ if (this.loginOngoing) {
125
+ throw new Error('Authentication already in progress');
126
+ }
127
+
128
+ const staleTimeout = setTimeout(() => (this.loginStale = true), 10000);
129
+ const initialState = {
130
+ dismissed: this.dismissed,
131
+ ignorePreviousSessionError: this.ignorePreviousSessionError,
132
+ previousSession: this.previousSession,
133
+ };
134
+ this.loginOngoing = true;
135
+
136
+ try {
137
+ const profile = await this.getUserProfile(loginUrl);
138
+ const oidcIssuerUrl = profile?.oidcIssuerUrl ?? urlRoot(profile?.webId ?? loginUrl);
139
+ const authenticator = await this.bootAuthenticator(authenticatorName);
140
+ const { domain: loginDomain } = requireUrlParse(loginUrl);
141
+
142
+ this.setState({
143
+ dismissed: false,
144
+ ignorePreviousSessionError: true,
145
+ previousSession: objectWithoutEmpty({
146
+ profile,
147
+ loginUrl,
148
+ authenticator: authenticatorName,
149
+ error: translateWithDefault(
150
+ 'auth.stuckConnecting',
151
+ `We didn't hear back from the identity provider at \`${loginDomain}\`, maybe try reconnecting?`,
152
+ ),
153
+ }),
154
+ });
155
+
156
+ await Events.emit('auth:before-login');
157
+
158
+ // This should redirect away from the app, so in most cases
159
+ // the rest of the code won't be reached.
160
+ await authenticator.login(oidcIssuerUrl);
161
+
162
+ return true;
163
+ } catch (error) {
164
+ this.setState(initialState);
165
+
166
+ if (error instanceof AuthenticationCancelledError) {
167
+ return false;
168
+ }
169
+
170
+ if (options.fallbackUrl) {
171
+ return after().then(() => this.login(options.fallbackUrl ?? '', objectWithout(options, 'fallbackUrl')));
172
+ }
173
+
174
+ handleLoginError(error);
175
+
176
+ return false;
177
+ } finally {
178
+ clearTimeout(staleTimeout);
179
+
180
+ this.setState({
181
+ loginOngoing: false,
182
+ loginStale: false,
183
+ });
184
+ }
185
+ }
186
+
187
+ public async reconnect(options: ReconnectOptions = {}): Promise<void> {
188
+ const { force, ...loginOptions } = options;
189
+ if (this.isLoggedIn() || !this.previousSession || (this.previousSession.error !== null && !force)) {
190
+ return;
191
+ }
192
+
193
+ await this.login(this.previousSession.loginUrl, {
194
+ authenticator: this.previousSession.authenticator,
195
+ ...loginOptions,
196
+ });
197
+ }
198
+
199
+ public async logout(force: boolean = false): Promise<void> {
200
+ const confirmLogout =
201
+ force ||
202
+ (await UI.confirm(
203
+ translateWithDefault('solid.logoutConfirmTitle', 'Log out from this device?'),
204
+ translateWithDefault(
205
+ 'solid.logoutConfirmMessage',
206
+ 'Logging out will remove all the data from this device, ' +
207
+ 'but you\'ll still have it in your Solid POD.',
208
+ ),
209
+ {
210
+ acceptText: translateWithDefault('solid.logoutConfirmAccept', 'Log out'),
211
+ acceptColor: Colors.Danger,
212
+ cancelText: translateWithDefault(
213
+ 'solid.logoutConfirmCancel',
214
+ translateWithDefault('ui.cancel', 'Cancel'),
215
+ ),
216
+ },
217
+ ));
218
+
219
+ if (!confirmLogout) {
220
+ return;
221
+ }
222
+
223
+ if (!this.previousSession) {
224
+ return;
225
+ }
226
+
227
+ await UI.loading(translateWithDefault('solid.logoutOngoing', 'Logging out...'), async () => {
228
+ this.setState({
229
+ previousSession: null,
230
+ ...DEFAULT_STATE,
231
+ });
232
+ this.isLoggedIn() && (await this.authenticator.logout());
233
+
234
+ await Events.emit('auth:logout');
235
+ await Events.emit('auth:after-logout');
236
+ });
237
+ }
238
+
239
+ public async whenLoggedIn<T>(callback: (session: AuthSession) => T): Promise<T> {
240
+ if (this.isLoggedIn()) {
241
+ return callback(this.session);
242
+ }
243
+
244
+ return new Promise((resolve) => {
245
+ const onLogin = (session: AuthSession) => resolve(callback(session));
246
+
247
+ Events.once('auth:login', onLogin);
248
+ });
249
+ }
250
+
251
+ public dismiss(): void {
252
+ this.setState({ dismissed: true });
253
+ }
254
+
255
+ public async findOrCreatePrivateTypeIndex(): Promise<SolidTypeIndex> {
256
+ return (await this.findPrivateTypeIndex()) ?? (await this.createPrivateTypeIndex());
257
+ }
258
+
259
+ public async findPrivateTypeIndex(): Promise<SolidTypeIndex | null> {
260
+ const { webId, privateTypeIndexUrl } = this.requireUser();
261
+ const engine = this.requireAuthenticator().engine;
262
+
263
+ if (!privateTypeIndexUrl) {
264
+ return null;
265
+ }
266
+
267
+ return asyncMemo(`${webId}-privateTypeIndex`, () =>
268
+ SolidTypeIndex.withEngine(engine).find(privateTypeIndexUrl));
269
+ }
270
+
271
+ public async findOrCreatePublicTypeIndex(): Promise<SolidTypeIndex> {
272
+ return (await this.findPublicTypeIndex()) ?? (await this.createPublicTypeIndex());
273
+ }
274
+
275
+ protected async findPublicTypeIndex(): Promise<SolidTypeIndex | null> {
276
+ const { webId, publicTypeIndexUrl } = this.requireUser();
277
+ const engine = this.requireAuthenticator().engine;
278
+
279
+ if (!publicTypeIndexUrl) {
280
+ return null;
281
+ }
282
+
283
+ return asyncMemo(`${webId}-publicTypeIndex`, () => SolidTypeIndex.withEngine(engine).find(publicTypeIndexUrl));
284
+ }
285
+
286
+ public async createPrivateContainer(options: {
287
+ url: string;
288
+ name?: string;
289
+ registerFor?: SolidModelConstructor;
290
+ reuseExisting?: boolean;
291
+ }): Promise<SolidContainer> {
292
+ const engine = this.requireAuthenticator().engine;
293
+
294
+ return SolidContainer.withEngine(engine, async () => {
295
+ const existingContainer = await SolidContainer.find(options.url);
296
+
297
+ if (existingContainer && !options.reuseExisting) {
298
+ throw new ContainerAlreadyInUse(existingContainer);
299
+ }
300
+
301
+ const container = existingContainer ?? new SolidContainer({ url: options.url, name: options.name });
302
+
303
+ await container.save();
304
+
305
+ if (options.registerFor) {
306
+ const typeIndex = await this.findOrCreatePrivateTypeIndex();
307
+
308
+ await container.register(typeIndex, options.registerFor);
309
+ }
310
+
311
+ return container;
312
+ });
313
+ }
314
+
315
+ protected async boot(): Promise<void> {
316
+ await Errors.booted;
317
+
318
+ if (hasLocationQueryParameter('authenticator')) {
319
+ this.setState({
320
+ preferredAuthenticator: getLocationQueryParameter('authenticator') as AuthenticatorName,
321
+ });
322
+ }
323
+
324
+ if (hasLocationQueryParameter('refreshProfile')) {
325
+ this.setState({
326
+ staleProfiles: arrayUnique(
327
+ this.staleProfiles.concat(arrayFilter([this.session?.user.webId, this.previousSession?.loginUrl])),
328
+ ),
329
+ });
330
+ }
331
+
332
+ await this.restorePreviousSession();
333
+ await this.startupReconnect();
334
+ }
335
+
336
+ protected async createPrivateTypeIndex(): Promise<SolidTypeIndex> {
337
+ const user = this.requireUser();
338
+ const authenticator = this.requireAuthenticator();
339
+ const typeIndex = await SolidTypeIndex.withEngine(authenticator.engine).createPrivate(user);
340
+
341
+ return tap(typeIndex, () => {
342
+ user.privateTypeIndexUrl = typeIndex.url;
343
+
344
+ setAsyncMemo(`${user.webId}-privateTypeIndex`, typeIndex);
345
+
346
+ this.rememberProfile(user);
347
+ });
348
+ }
349
+
350
+ public async createPublicTypeIndex(): Promise<SolidTypeIndex> {
351
+ const user = this.requireUser();
352
+ const authenticator = this.requireAuthenticator();
353
+ const typeIndex = await SolidTypeIndex.withEngine(authenticator.engine).createPublic(user);
354
+
355
+ return tap(typeIndex, () => {
356
+ user.privateTypeIndexUrl = typeIndex.url;
357
+
358
+ setAsyncMemo(`${user.webId}-publicTypeIndex`, typeIndex);
359
+
360
+ this.rememberProfile(user);
361
+ });
362
+ }
363
+
364
+ private async restorePreviousSession(): Promise<void> {
365
+ if (!this.previousSession) {
366
+ return;
367
+ }
368
+
369
+ await this.bootAuthenticator(this.previousSession.authenticator);
370
+ }
371
+
372
+ private async startupReconnect(): Promise<void> {
373
+ if (!this.reconnectOnStartup()) {
374
+ return;
375
+ }
376
+
377
+ await this.reconnect();
378
+ }
379
+
380
+ private reconnectOnStartup(): boolean {
381
+ if (hasLocationQueryParameter('autoReconnect')) {
382
+ return parseBoolean(getLocationQueryParameter('autoReconnect'));
383
+ }
384
+
385
+ return this.autoReconnect;
386
+ }
387
+
388
+ private async bootAuthenticator(name: AuthenticatorName): Promise<Authenticator> {
389
+ const authenticator = getAuthenticator(name);
390
+
391
+ authenticator.addListener({
392
+ onSessionStarted: async (session) => {
393
+ this.setState({
394
+ session,
395
+ previousSession: {
396
+ authenticator: authenticator.name,
397
+ loginUrl: session.loginUrl,
398
+ profile: session.user,
399
+ error: null,
400
+ },
401
+ });
402
+
403
+ if (
404
+ session.user.cloaked ||
405
+ !session.user.writableProfileUrl ||
406
+ this.staleProfiles.includes(session.user.webId)
407
+ ) {
408
+ await this.refreshUserProfile();
409
+ }
410
+
411
+ App.plugin('@aerogel/offline-first') || setEngine(session.authenticator.engine);
412
+
413
+ SolidACLAuthorization.setEngine(session.authenticator.engine);
414
+ SolidTypeIndex.setEngine(session.authenticator.engine);
415
+ SolidDocument.setEngine(session.authenticator.engine);
416
+
417
+ await Events.emit('auth:login', session);
418
+ },
419
+ onSessionFailed: async (loginUrl, error) => {
420
+ this.setState({
421
+ session: null,
422
+ previousSession: {
423
+ authenticator: authenticator.name,
424
+ loginUrl,
425
+ error,
426
+ },
427
+ });
428
+ },
429
+ onSessionEnded: () => this.setState({ session: null }),
430
+ onAuthenticatedFetchReady: (fetch) => Events.emit('auth:fetch-ready', fetch),
431
+ });
432
+
433
+ await authenticator.boot();
434
+
435
+ return authenticator;
436
+ }
437
+
438
+ private rememberProfile(profile: SolidUserProfile): void {
439
+ this.setState({
440
+ profiles: {
441
+ ...this.profiles,
442
+ [profile.webId]: profile,
443
+ },
444
+ staleProfiles: arrayWithout(this.staleProfiles, profile.webId),
445
+ });
446
+ }
447
+
448
+ }
449
+
450
+ export default facade(SolidService);
451
+
452
+ declare module '@aerogel/core' {
453
+ export interface EventsPayload {
454
+ 'auth:fetch-ready': Fetch;
455
+ 'auth:before-login': void;
456
+ 'auth:login': AuthSession;
457
+ 'auth:logout': void;
458
+ 'auth:after-logout': void;
459
+ }
460
+ }
@@ -0,0 +1,7 @@
1
+ declare module '*.vue' {
2
+ import type { ComponentOptions } from 'vue';
3
+
4
+ const component: ComponentOptions;
5
+
6
+ export default component;
7
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "types": ["unplugin-icons/types/vue3", "@aerogel/vite/dist/virtual"],
6
+ "paths": {
7
+ "@/*": ["./src/*"]
8
+ }
9
+ },
10
+ "include": ["src/**/*.ts", "src/**/*.vue"]
11
+ }