├── .eslintignore
├── .eslintrc.cjs
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── README.md
├── package-lock.json
├── package.json
├── playwright.config.ts
├── src
├── app.d.ts
├── app.html
├── lib
│ ├── client
│ │ ├── firebase.svelte.ts
│ │ └── state.svelte.ts
│ ├── images
│ │ ├── github.svg
│ │ ├── svelte-logo.svg
│ │ ├── svelte-welcome.png
│ │ └── svelte-welcome.webp
│ ├── misc
│ │ └── functions.ts
│ ├── models
│ │ ├── count.ts
│ │ ├── doc.ts
│ │ └── types.ts
│ └── server
│ │ └── firebase.ts
└── routes
│ ├── +layout.svelte
│ ├── +page.server.ts
│ ├── +page.svelte
│ ├── Counter.svelte
│ ├── Header.svelte
│ ├── api
│ └── token
│ │ └── +server.ts
│ └── styles.css
├── static
├── favicon.png
└── robots.txt
├── svelte.config.js
├── tests
└── test.ts
├── tsconfig.json
└── vite.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type { import("eslint").Linter.Config } */
2 | module.exports = {
3 | root: true,
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:svelte/recommended',
8 | 'prettier'
9 | ],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['@typescript-eslint'],
12 | parserOptions: {
13 | sourceType: 'module',
14 | ecmaVersion: 2020,
15 | extraFileExtensions: ['.svelte']
16 | },
17 | env: {
18 | browser: true,
19 | es2017: true,
20 | node: true
21 | },
22 | overrides: [
23 | {
24 | files: ['*.svelte'],
25 | parser: 'svelte-eslint-parser',
26 | parserOptions: {
27 | parser: '@typescript-eslint/parser'
28 | }
29 | }
30 | ]
31 | };
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 | .vercel
10 | .output
11 | vite.config.js.timestamp-*
12 | vite.config.ts.timestamp-*
13 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore files for PNPM, NPM and YARN
2 | pnpm-lock.yaml
3 | package-lock.json
4 | yarn.lock
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-svelte"],
7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SvelteKit + Firebase + SSR with user data
2 |
3 | This is an example/boilerplate/starter of the SvelteKit sample app with Firebase authentication and SSR that has user data.
4 |
5 | ## Features
6 |
7 | - SSR is generated with user data
8 | - Works with Firebase Emulator
9 | - Svelte 5 runes
10 | - Data is reactive
11 |
12 | ## Live example
13 |
14 | https://sveltekit-firebase-ssr.vercel.app/
15 |
16 | ## Setup
17 |
18 | Create a `.env` file at the root of the folder with the following entries:
19 |
20 | ```
21 | PUBLIC_FIREBASE_CLIENT_CONFIG=Your **client** Firebase config json, stringified
22 | FIREBASE_SERVER_CONFIG=Your **server** Firebase config json, stringified
23 |
24 | PUBLIC_USE_EMULATOR=true|false
25 | PUBLIC_FIREBASE_EMULATOR_PROJECT=your-project-name
26 | PUBLIC_FIREBASE_AUTH_EMULATOR_HOST=localhost
27 | PUBLIC_FIREBASE_AUTH_EMULATOR_PORT=9099
28 | PUBLIC_FIRESTORE_EMULATOR_HOST=localhost
29 | PUBLIC_FIRESTORE_EMULATOR_PORT=8080
30 | ```
31 |
32 | ### PUBLIC_FIREBASE\_**CLIENT**\_CONFIG
33 |
34 | This value will be sent to the client in the user's session.
35 |
36 | The (non-stringified) json has this shape:
37 |
38 | ```
39 | {
40 | "apiKey": "",
41 | "authDomain": "",
42 | "databaseURL": "",
43 | "projectId": "",
44 | "storageBucket": "",
45 | "messagingSenderId": "",
46 | "appId": "",
47 | "measurementId": ""
48 | }
49 | ```
50 |
51 | To obtain the client config, log in to the Firebase console, click the ⚙️ (settings icon), then select `Project Settings` and in the `General` tab the config json will be under `Your apps`.
52 |
53 | ### FIREBASE\_**SERVER**\_CONFIG
54 |
55 | This value is only used to retrieve data from Firebase on the server. See `src/lib/server/firebase.ts`
56 |
57 | The (non-stringified) json has this shape:
58 |
59 | ```
60 | {
61 | "type": "",
62 | "project_id": "",
63 | "private_key_id": "",
64 | "private_key": "",
65 | "client_email": "",
66 | "client_id": "",
67 | "auth_uri": "",
68 | "token_uri": "",
69 | "auth_provider_x509_cert_url": "",
70 | "client_x509_cert_url": ""
71 | }
72 | ```
73 |
74 | To obtain the admin server config, log in to the Firebase console, click the ⚙️ (settings icon), then select `Project Settings` and then the `Service accounts` tab. In the `Firebase Admin SDK` click `Generate new private key`.
75 |
76 | These credentials contain a private key that should be kept secret (i.e. not shared or committed to Git)
77 |
78 | ## Reading data
79 |
80 | Because reading on the server requires `firebase-admin` which uses a project's private key, DB operations are separated into the following:
81 |
82 | - `/src/lib/server/firebase.ts` for the server.
83 | - `/src/lib/utils/firebase.ts` for the client.
84 | - `/src/routes/+page.server.ts` to get the components' initial data from both client and server.
85 |
86 | ## Models
87 |
88 | At risk of angering the FP gods I decided to go with classes for the document models.
89 | `/src/lib/models/doc.ts` is the base class for Firebase documents.
90 | `/src/lib/models/count.ts` holds the definition of the `Count` item. The constructor adds the fields it wants to persist in the DB (in this case it's `count` and `uid`).
91 |
92 | ## Firebase reactivity
93 |
94 | The `Counter` component shows how one can subscribe to Firebase changes. `useDocument` gets the data from the server, creates a `Document` and loads the object's data properties on Firebase's `onSnapshot`.
95 |
96 | You can open 2 browser windows and see how one changes with the other (as long as they're both logged in with the same user).
97 |
98 | The `Counter` component doesn't display on the home page if the user isn't logged in.
99 |
100 | ## Loading user data.
101 |
102 | `+page.svelte` declares `let { data } = $props();` which is populated by the return of the `load` method in `+page.server.ts`. `+page.svelte` then checks if `data.userCount` has something and passes the value to the component ``.
103 |
104 | ## Update Firebase Cloud Firestore Rules
105 |
106 | You will need to update your Firestore security rules to grant the necessary permissions for your SvelteKit app. You can do this in the Firebase console by following these steps:
107 |
108 | 1. Go to the Firebase Console.
109 | 2. Select your project.
110 | 3. In the left-hand menu, click on "Firestore Database."
111 | 4. Click on the "Rules" tab.
112 |
113 | You'll now see the security rules for your Firestore database. You need to update these rules to allow read and/or write access for authenticated users, depending on your app requirements.
114 |
115 | Here's an example of security rules that allow read and write access to all documents in the database for authenticated users:
116 |
117 | ```javascript
118 | rules_version = '2';
119 | service cloud.firestore {
120 | match /databases/{database}/documents {
121 | match /{document=**} {
122 | allow read, write: if request.auth != null;
123 | }
124 | }
125 | }
126 | ```
127 |
128 | These rules grant read and write permissions to any authenticated user. You may need to refine these rules further based on your app's specific requirements, such as allowing access only to specific collections or documents, or based on user roles.
129 |
130 | After updating your security rules, click "Publish" to apply the changes.
131 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sveltekit-firebase-ssr",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "dev": "vite dev",
6 | "build": "vite build",
7 | "preview": "vite preview",
8 | "test": "npm run test:integration && npm run test:unit",
9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
11 | "lint": "prettier --check . && eslint .",
12 | "format": "prettier --write .",
13 | "test:integration": "playwright test",
14 | "test:unit": "vitest",
15 | "rei": "rm -rf node_modules .svelte-kit package-lock.json && npm i --legacy-peer-deps"
16 | },
17 | "devDependencies": {
18 | "@fontsource/fira-mono": "5.0.13",
19 | "@playwright/test": "1.45.1",
20 | "@sveltejs/adapter-auto": "3.2.2",
21 | "@sveltejs/adapter-vercel": "5.4.1",
22 | "@sveltejs/kit": "2.5.18",
23 | "@sveltejs/vite-plugin-svelte": "3.1.1",
24 | "@types/eslint": "8.56.10",
25 | "@typescript-eslint/eslint-plugin": "7.15.0",
26 | "@typescript-eslint/parser": "7.15.0",
27 | "eslint": "8.56.0",
28 | "eslint-config-prettier": "9.1.0",
29 | "eslint-plugin-svelte": "2.41.0",
30 | "prettier": "3.3.2",
31 | "prettier-plugin-svelte": "3.2.5",
32 | "svelte": "5.0.0-next.175",
33 | "svelte-check": "3.8.4",
34 | "svelte-eslint-parser": "0.39.2",
35 | "tslib": "2.6.3",
36 | "typescript": "5.5.3",
37 | "vite": "5.3.3",
38 | "vitest": "1.6.0"
39 | },
40 | "type": "module",
41 | "dependencies": {
42 | "firebase": "10.12.3",
43 | "firebase-admin": "12.2.0"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import type { PlaywrightTestConfig } from '@playwright/test';
2 |
3 | const config: PlaywrightTestConfig = {
4 | webServer: {
5 | command: 'npm run build && npm run preview',
6 | port: 4173
7 | },
8 | testDir: 'tests',
9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/
10 | };
11 |
12 | export default config;
13 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://kit.svelte.dev/docs/types#app
2 | // for information about these interfaces
3 | declare global {
4 | namespace App {
5 | // interface Error {}
6 | // interface Locals {}
7 | // interface PageData {}
8 | // interface PageState {}
9 | // interface Platform {}
10 | }
11 | }
12 |
13 | export {};
14 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | %sveltekit.head%
8 |
9 |
10 | %sveltekit.body%
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/lib/client/firebase.svelte.ts:
--------------------------------------------------------------------------------
1 | import type { FirebaseApp } from 'firebase/app';
2 | import type { Firestore, QueryFieldFilterConstraint } from 'firebase/firestore';
3 | import { initializeApp } from 'firebase/app';
4 | import {
5 | collection,
6 | getFirestore,
7 | query,
8 | addDoc,
9 | doc,
10 | onSnapshot,
11 | setDoc,
12 | deleteDoc,
13 | getDoc,
14 | connectFirestoreEmulator,
15 | getDocs
16 | } from 'firebase/firestore';
17 | import {
18 | getAuth,
19 | signInWithRedirect,
20 | signOut as _signOut,
21 | GoogleAuthProvider,
22 | onIdTokenChanged,
23 | connectAuthEmulator,
24 | signInWithEmailAndPassword as _signInWithEmailAndPassword
25 | } from 'firebase/auth';
26 |
27 | import { browser } from '$app/environment';
28 | import { invalidateAll } from '$app/navigation';
29 |
30 | import {
31 | PUBLIC_FIREBASE_AUTH_EMULATOR_HOST,
32 | PUBLIC_FIREBASE_AUTH_EMULATOR_PORT,
33 | PUBLIC_FIREBASE_CLIENT_CONFIG,
34 | PUBLIC_FIREBASE_EMULATOR_PROJECT,
35 | PUBLIC_USE_EMULATOR,
36 | PUBLIC_FIRESTORE_EMULATOR_HOST,
37 | PUBLIC_FIRESTORE_EMULATOR_PORT
38 | } from '$env/static/public';
39 | import { useUser } from './state.svelte';
40 | import type { Doc } from '$lib/models/doc';
41 | import { getDbObject } from '$lib/misc/functions';
42 |
43 | const user = useUser();
44 |
45 | async function setToken(token: string) {
46 | const options = {
47 | method: 'POST',
48 | headers: {
49 | 'Content-Type': 'application/json;charset=utf-8'
50 | },
51 | body: JSON.stringify({ token })
52 | };
53 |
54 | await fetch('/api/token', options);
55 | }
56 |
57 | function listenForAuthChanges() {
58 | const auth = getAuth();
59 |
60 | onIdTokenChanged(
61 | auth,
62 | async (newUser) => {
63 | if (newUser) {
64 | const token = await newUser.getIdToken();
65 | await setToken(token);
66 | user.value = { email: newUser.email, uid: newUser.uid };
67 | } else {
68 | await setToken('');
69 | user.value = null;
70 | }
71 | await invalidateAll();
72 | },
73 | (err) => console.error(err.message)
74 | );
75 | }
76 |
77 | export let app: FirebaseApp;
78 | export let db: Firestore;
79 | export function initializeFirebase() {
80 | if (!browser) {
81 | throw new Error("Can't use the Firebase client on the server.");
82 | }
83 | if (!app) {
84 | if (PUBLIC_USE_EMULATOR === 'true') {
85 | app = initializeApp({
86 | projectId: PUBLIC_FIREBASE_EMULATOR_PROJECT,
87 | apiKey: 'dummy-key',
88 | authDomain: PUBLIC_FIREBASE_AUTH_EMULATOR_HOST
89 | });
90 | connectAuthEmulator(
91 | getAuth(),
92 | `http://${PUBLIC_FIREBASE_AUTH_EMULATOR_HOST}:${PUBLIC_FIREBASE_AUTH_EMULATOR_PORT}`,
93 | {
94 | disableWarnings: true
95 | }
96 | );
97 |
98 | db = getFirestore();
99 | connectFirestoreEmulator(
100 | db,
101 | PUBLIC_FIRESTORE_EMULATOR_HOST,
102 | parseInt(PUBLIC_FIRESTORE_EMULATOR_PORT)
103 | );
104 | } else {
105 | app = initializeApp(JSON.parse(PUBLIC_FIREBASE_CLIENT_CONFIG));
106 | db = getFirestore(app);
107 | listenForAuthChanges();
108 | }
109 |
110 | listenForAuthChanges();
111 | }
112 | }
113 |
114 | export async function signInWithGoogle() {
115 | const auth = getAuth();
116 | await signInWithRedirect(auth, new GoogleAuthProvider());
117 | }
118 |
119 | export async function signInWithEmailAndPassword(email: string, password: string) {
120 | const auth = getAuth();
121 | await _signInWithEmailAndPassword(auth, email, password);
122 | }
123 |
124 | export async function signOut() {
125 | const auth = getAuth();
126 | await _signOut(auth);
127 | }
128 |
129 | export async function saveDocument(type: { new (): T }, path: string, document: T) {
130 | const dbObject = getDbObject(type, document);
131 |
132 | if (document._id) {
133 | await setDoc(doc(db, path, document._id), dbObject);
134 | } else {
135 | const todoRef = await addDoc(collection(db, path), dbObject);
136 | document._id = todoRef.id;
137 | }
138 | }
139 |
140 | export async function getDocument(
141 | type: { new (): T },
142 | path: string,
143 | id: string
144 | ): Promise {
145 | const docSnap = await getDoc(doc(db, path, id));
146 | if (docSnap.exists()) {
147 | const ret = new type();
148 | ret._load(docSnap.data());
149 | ret._id = docSnap.id;
150 | return ret;
151 | } else {
152 | return null;
153 | }
154 | }
155 |
156 | export async function getDocuments(
157 | type: { new (): T },
158 | path: string,
159 | search: QueryFieldFilterConstraint | Array
160 | ): Promise> {
161 | const wheres = Array.isArray(search) ? search : [search];
162 | const q = query(collection(db, path), ...wheres);
163 | const docSnap = await getDocs(q);
164 | return docSnap.docs.map((d) => {
165 | const ret = new type();
166 | ret._load(d.data());
167 | ret._id = d.id;
168 | return ret;
169 | });
170 | }
171 |
172 | export async function deleteDocument(path: string, id: string) {
173 | await deleteDoc(doc(db, path, id));
174 | }
175 |
176 | export function useDocument(
177 | type: { new (): T },
178 | path: string,
179 | id: string,
180 | defaultValue: T | undefined = undefined,
181 | onDeleted: () => void = () => undefined
182 | ) {
183 | let obj: T | undefined = $state(defaultValue);
184 | $effect(() => {
185 | const dbUnsubscribe = onSnapshot(doc(db, path, id), (docSnap) => {
186 | if (docSnap.exists()) {
187 | const newDoc = new type();
188 | newDoc._load(docSnap.data());
189 | newDoc._id = docSnap.id;
190 | obj = newDoc;
191 | } else {
192 | obj = undefined;
193 | onDeleted();
194 | dbUnsubscribe();
195 | }
196 | });
197 | return () => {
198 | dbUnsubscribe();
199 | };
200 | });
201 |
202 | return {
203 | get value() {
204 | return obj;
205 | }
206 | };
207 | }
208 |
209 | export function useDocuments(
210 | type: { new (): T },
211 | path: string,
212 | search: QueryFieldFilterConstraint | Array,
213 | initialData: Array = []
214 | ) {
215 | let arr: Array = $state(initialData);
216 | $effect(() => {
217 | const wheres = Array.isArray(search) ? search : [search];
218 | const q = query(collection(db, path), ...wheres);
219 | const dbUnsubscribe = onSnapshot(q, (docs) => {
220 | const newDocuments: Array = [];
221 | docs.forEach((docSnap) => {
222 | const newDoc = new type();
223 | newDoc._load(docSnap.data());
224 | newDoc._id = docSnap.id;
225 | newDocuments.push(newDoc);
226 | });
227 | arr = newDocuments;
228 | });
229 | return () => {
230 | dbUnsubscribe();
231 | };
232 | });
233 |
234 | return {
235 | get value() {
236 | return arr;
237 | }
238 | };
239 | }
240 |
--------------------------------------------------------------------------------
/src/lib/client/state.svelte.ts:
--------------------------------------------------------------------------------
1 | import type { NotUndefined, UserInfo } from '$lib/models/types';
2 |
3 | export function reactive(initialValue: T) {
4 | let value: T = $state(initialValue);
5 | return {
6 | get value() {
7 | return value;
8 | },
9 | set value(newValue: T) {
10 | value = newValue;
11 | }
12 | };
13 | }
14 |
15 | function shared(initialValue: T) {
16 | let value: T = $state(initialValue);
17 | return (updatedValue?: NotUndefined) => {
18 | if (typeof updatedValue !== 'undefined') {
19 | value = updatedValue;
20 | }
21 | return {
22 | get value() {
23 | return value;
24 | },
25 | set value(newValue: T) {
26 | value = newValue;
27 | }
28 | };
29 | };
30 | }
31 |
32 | export const useUser = shared(null);
33 |
--------------------------------------------------------------------------------
/src/lib/images/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/images/svelte-logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/lib/images/svelte-welcome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ManuelDeLeon/sveltekit-firebase-ssr/6fdf9f1b7adc6af5ad46de29d633042f98f93409/src/lib/images/svelte-welcome.png
--------------------------------------------------------------------------------
/src/lib/images/svelte-welcome.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ManuelDeLeon/sveltekit-firebase-ssr/6fdf9f1b7adc6af5ad46de29d633042f98f93409/src/lib/images/svelte-welcome.webp
--------------------------------------------------------------------------------
/src/lib/misc/functions.ts:
--------------------------------------------------------------------------------
1 | import type { Doc } from '$lib/models/doc';
2 | import type { AnyObject } from '$lib/models/types';
3 |
4 | export function getDbObject(type: { new (): T }, document: T): Partial {
5 | const doc = new type();
6 | doc._load(document as AnyObject);
7 | delete doc._id;
8 |
9 | const obj: AnyObject = {};
10 | Object.keys(document).forEach((k) => {
11 | obj[k] = document[k as keyof Doc];
12 | });
13 | return obj;
14 | }
15 |
--------------------------------------------------------------------------------
/src/lib/models/count.ts:
--------------------------------------------------------------------------------
1 | import { Doc } from './doc';
2 | import type { AnyObject } from './types';
3 |
4 | export class Count extends Doc {
5 | constructor(data?: AnyObject) {
6 | super();
7 | this._load(data);
8 | }
9 | count = 0;
10 | uid = '';
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/models/doc.ts:
--------------------------------------------------------------------------------
1 | import type { AnyObject } from './types';
2 |
3 | export class Doc {
4 | _id: string = '';
5 | _load(data?: AnyObject) {
6 | if (data) {
7 | const target: AnyObject = this as AnyObject;
8 | Object.keys(data).forEach((key) => {
9 | if (Object.prototype.hasOwnProperty.call(target, key)) {
10 | target[key] = data[key];
11 | }
12 | });
13 | }
14 | }
15 | _obj() {
16 | const obj: AnyObject = {};
17 | Object.keys(this).forEach((k) => {
18 | obj[k] = this[k as keyof Doc];
19 | });
20 | return obj;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/models/types.ts:
--------------------------------------------------------------------------------
1 | export type AnyObject = Record;
2 | export type Fetch = (input: RequestInfo | URL, init?: RequestInit | undefined) => Promise;
3 |
4 | export type UserInfo = {
5 | email: string | null;
6 | uid: string;
7 | };
8 |
9 | export type NotUndefined = T extends undefined ? never : T;
10 |
--------------------------------------------------------------------------------
/src/lib/server/firebase.ts:
--------------------------------------------------------------------------------
1 | import type { Doc } from '$lib/models/doc';
2 | import type { AnyObject } from '$lib/models/types';
3 | import type { DecodedIdToken } from 'firebase-admin/auth';
4 | import type { FieldPath, WhereFilterOp } from 'firebase-admin/firestore';
5 |
6 | import { FIREBASE_SERVER_CONFIG } from '$env/static/private';
7 | import {
8 | PUBLIC_FIREBASE_AUTH_EMULATOR_HOST,
9 | PUBLIC_FIREBASE_AUTH_EMULATOR_PORT,
10 | PUBLIC_FIREBASE_EMULATOR_PROJECT,
11 | PUBLIC_FIRESTORE_EMULATOR_HOST,
12 | PUBLIC_FIRESTORE_EMULATOR_PORT,
13 | PUBLIC_USE_EMULATOR
14 | } from '$env/static/public';
15 | import admin from 'firebase-admin';
16 |
17 | function initializeFirebase() {
18 | if (!admin.apps.length) {
19 | if (PUBLIC_USE_EMULATOR === 'true') {
20 | process.env.FIREBASE_AUTH_EMULATOR_HOST = `${PUBLIC_FIREBASE_AUTH_EMULATOR_HOST}:${PUBLIC_FIREBASE_AUTH_EMULATOR_PORT}`;
21 | process.env.GCLOUD_PROJECT = PUBLIC_FIREBASE_EMULATOR_PROJECT;
22 | process.env.FIRESTORE_EMULATOR_HOST = `${PUBLIC_FIRESTORE_EMULATOR_HOST}:${PUBLIC_FIRESTORE_EMULATOR_PORT}`;
23 | admin.initializeApp();
24 | } else {
25 | const serviceAccount = JSON.parse(FIREBASE_SERVER_CONFIG);
26 | admin.initializeApp({
27 | credential: admin.credential.cert(serviceAccount),
28 | databaseURL: `https://${serviceAccount.project_id}.firebaseio.com`
29 | });
30 | }
31 | }
32 | }
33 |
34 | export async function decodeToken(token: string): Promise {
35 | if (!token || token === 'null' || token === 'undefined') return null;
36 | try {
37 | initializeFirebase();
38 | return await admin.auth().verifyIdToken(token);
39 | } catch (err) {
40 | console.error(err);
41 | return null;
42 | }
43 | }
44 |
45 | export async function createUserDocument(collectionPath: string, uid: string): Promise {
46 | initializeFirebase();
47 | const db = admin.firestore();
48 | const doc = await (await db.collection(collectionPath).add({ uid })).get();
49 |
50 | const document = doc.data() as Doc;
51 | document._id = doc.id;
52 | return document;
53 | }
54 |
55 | export type WhereClause = {
56 | fieldPath: FieldPath | string;
57 | opStr: WhereFilterOp;
58 | value: any; // eslint-disable-line @typescript-eslint/no-explicit-any
59 | };
60 | export async function getDocuments(
61 | type: { new (data: AnyObject): T },
62 | collectionPath: string,
63 | search: Array | WhereClause
64 | ): Promise> {
65 | initializeFirebase();
66 | const db = admin.firestore();
67 |
68 | const wheres = Array.isArray(search) ? search : [search];
69 | const firstWhere = wheres[0];
70 | let query = db
71 | .collection(collectionPath)
72 | .where(firstWhere.fieldPath, firstWhere.opStr, firstWhere.value);
73 | for (const where of wheres.slice(1)) {
74 | query = query.where(where.fieldPath, where.opStr, where.value);
75 | }
76 |
77 | const querySnapshot = await query.get();
78 | const list: Array = [];
79 | querySnapshot.forEach((doc) => {
80 | const document = new type(doc.data());
81 | document._id = doc.id;
82 | list.push(document);
83 | });
84 | return list;
85 | }
86 |
87 | export async function getDocument(
88 | type: { new (data: AnyObject): T },
89 | collectionPath: string,
90 | id: string
91 | ): Promise {
92 | initializeFirebase();
93 | const db = admin.firestore();
94 |
95 | const doc = await db.collection(collectionPath).doc(id).get();
96 | const data = doc.data();
97 | if (data) {
98 | const document = new type(data);
99 | document._id = doc.id;
100 | return document;
101 | }
102 | }
103 |
104 | export async function saveDocument(
105 | collectionPath: string,
106 | docToSave: T
107 | ): Promise {
108 | initializeFirebase();
109 | const db = admin.firestore();
110 | const collection = db.collection(collectionPath);
111 | if (docToSave._id) {
112 | await collection.doc(docToSave._id).set(docToSave._obj());
113 | } else {
114 | const ref = await collection.add(docToSave._obj());
115 | docToSave._id = ref.id;
116 | }
117 | return docToSave;
118 | }
119 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
26 |
27 |
28 |
64 |
--------------------------------------------------------------------------------
/src/routes/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { Count } from '$lib/models/count';
2 | import { decodeToken, getDocuments, saveDocument } from '$lib/server/firebase.js';
3 |
4 | type LoadResult = {
5 | userInfo?: { uid: string; email?: string };
6 | userCount?: Partial;
7 | };
8 |
9 | export async function load({ cookies, fetch }): Promise {
10 | const token = cookies.get('token') || '';
11 | const decodedToken = await decodeToken(token);
12 |
13 | if (decodedToken) {
14 | const { uid, email } = decodedToken;
15 |
16 | const docs = await getDocuments(Count, 'counters', {
17 | fieldPath: 'uid',
18 | opStr: '==',
19 | value: uid
20 | });
21 |
22 | const userCount: Count = docs.length
23 | ? docs[0]
24 | : await saveDocument('counters', new Count({ uid, count: 0 }));
25 |
26 | return { userInfo: { uid, email }, userCount: userCount._obj() };
27 | }
28 | return { userInfo: undefined, userCount: undefined };
29 | }
30 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 | Home
52 |
53 |
54 |
55 |
101 |
102 |
131 |
--------------------------------------------------------------------------------
/src/routes/Counter.svelte:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
40 |
41 |
42 |
43 | {Math.floor(countDoc.value?.count || 0)}
44 |
45 |
46 |
47 |
52 |
53 |
54 | {#if user.value}
55 |
56 |
57 | User data on Counter.svelte: ({user.value.email})
58 |
59 |
60 | {/if}
61 |
62 |
123 |
--------------------------------------------------------------------------------
/src/routes/Header.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
12 |
13 |
26 |
27 |
32 |
33 |
34 |
123 |
--------------------------------------------------------------------------------
/src/routes/api/token/+server.ts:
--------------------------------------------------------------------------------
1 | import { json } from '@sveltejs/kit';
2 | import type { RequestHandler } from './$types';
3 |
4 | export const POST: RequestHandler = async ({ request, cookies }) => {
5 | const payload = await request.json();
6 | const token: string = payload.token || '';
7 |
8 | if (token) {
9 | cookies.set('token', token, {
10 | path: '/',
11 | httpOnly: true,
12 | secure: true
13 | });
14 | } else {
15 | cookies.delete('token', { path: '/', httpOnly: true });
16 | }
17 |
18 | return json({});
19 | };
20 |
--------------------------------------------------------------------------------
/src/routes/styles.css:
--------------------------------------------------------------------------------
1 | @import '@fontsource/fira-mono';
2 |
3 | :root {
4 | --font-body: Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
5 | Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
6 | --font-mono: 'Fira Mono', monospace;
7 | --color-bg-0: rgb(202, 216, 228);
8 | --color-bg-1: hsl(209, 36%, 86%);
9 | --color-bg-2: hsl(224, 44%, 95%);
10 | --color-theme-1: #ff3e00;
11 | --color-theme-2: #4075a6;
12 | --color-text: rgba(0, 0, 0, 0.7);
13 | --column-width: 42rem;
14 | --column-margin-top: 4rem;
15 | font-family: var(--font-body);
16 | color: var(--color-text);
17 | }
18 |
19 | body {
20 | min-height: 100vh;
21 | margin: 0;
22 | background-attachment: fixed;
23 | background-color: var(--color-bg-1);
24 | background-size: 100vw 100vh;
25 | background-image: radial-gradient(
26 | 50% 50% at 50% 50%,
27 | rgba(255, 255, 255, 0.75) 0%,
28 | rgba(255, 255, 255, 0) 100%
29 | ),
30 | linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%);
31 | }
32 |
33 | h1,
34 | h2,
35 | p {
36 | font-weight: 400;
37 | }
38 |
39 | p {
40 | line-height: 1.5;
41 | }
42 |
43 | a {
44 | color: var(--color-theme-1);
45 | text-decoration: none;
46 | }
47 |
48 | a:hover {
49 | text-decoration: underline;
50 | }
51 |
52 | h1 {
53 | font-size: 2rem;
54 | text-align: center;
55 | }
56 |
57 | h2 {
58 | font-size: 1rem;
59 | }
60 |
61 | pre {
62 | font-size: 16px;
63 | font-family: var(--font-mono);
64 | background-color: rgba(255, 255, 255, 0.45);
65 | border-radius: 3px;
66 | box-shadow: 2px 2px 6px rgb(255 255 255 / 25%);
67 | padding: 0.5em;
68 | overflow-x: auto;
69 | color: var(--color-text);
70 | }
71 |
72 | .text-column {
73 | display: flex;
74 | max-width: 48rem;
75 | flex: 0.6;
76 | flex-direction: column;
77 | justify-content: center;
78 | margin: 0 auto;
79 | }
80 |
81 | input,
82 | button {
83 | font-size: inherit;
84 | font-family: inherit;
85 | }
86 |
87 | button:focus:not(:focus-visible) {
88 | outline: none;
89 | }
90 |
91 | @media (min-width: 720px) {
92 | h1 {
93 | font-size: 2.4rem;
94 | }
95 | }
96 |
97 | .visually-hidden {
98 | border: 0;
99 | clip: rect(0 0 0 0);
100 | height: auto;
101 | margin: 0;
102 | overflow: hidden;
103 | padding: 0;
104 | position: absolute;
105 | width: 1px;
106 | white-space: nowrap;
107 | }
108 |
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ManuelDeLeon/sveltekit-firebase-ssr/6fdf9f1b7adc6af5ad46de29d633042f98f93409/static/favicon.png
--------------------------------------------------------------------------------
/static/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
7 | // for more information about preprocessors
8 | preprocess: vitePreprocess(),
9 |
10 | kit: {
11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters.
14 | adapter: adapter()
15 | },
16 | compilerOptions: {
17 | runes: true
18 | }
19 | };
20 |
21 | export default config;
22 |
--------------------------------------------------------------------------------
/tests/test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test('about page has expected h1', async ({ page }) => {
4 | await page.goto('/about');
5 | await expect(page.getByRole('heading', { name: 'About this app' })).toBeVisible();
6 | });
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "moduleResolution": "bundler"
13 | }
14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
16 | //
17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
18 | // from the referenced tsconfig.json - TypeScript does not merge them in
19 | }
20 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import { defineConfig } from 'vitest/config';
3 |
4 | export default defineConfig({
5 | plugins: [sveltekit()],
6 | test: {
7 | include: ['src/**/*.{test,spec}.{js,ts}']
8 | }
9 | });
10 |
--------------------------------------------------------------------------------