diff --git a/.env.default b/.env.default index 577673f..224f1e3 100644 --- a/.env.default +++ b/.env.default @@ -1 +1,3 @@ -POSTGRES_PASSWORD="The password for the postgres database" \ No newline at end of file +POSTGRES_PASSWORD="password" +VAPID_PRIVATE_KEY="" +BASE_URL="http://localhost:5173" \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..13fb711 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +POSTGRES_PASSWORD="testpassword" +VAPID_PRIVATE_KEY="privatekey" +BASE_URL="http://localhost:5173" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 647c8f1..0654967 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env -data/ +/data/ +/data-test/ node_modules/ build/ /public/entry.worker.js diff --git a/Dockerfile b/Dockerfile index 4e3eb0f..e0cdba3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,4 +3,4 @@ WORKDIR /tack-up-now COPY . . RUN npm install RUN npx -y remix vite:build -CMD npx tsx server.ts +CMD npx tsx ./api/data/migrate.ts && npx tsx server.ts diff --git a/api/app.ts b/api/app.ts new file mode 100644 index 0000000..2a230e0 --- /dev/null +++ b/api/app.ts @@ -0,0 +1,14 @@ +import express from "express" +import { postSubscription } from "./subscriptionEndpoints/postSubscription" +import { putSubscription } from "./subscriptionEndpoints/putSubscription" +import migrateToLatest from "./data/migrate" + +const app = express() +app.use(express.json()) + +app.get("/api", (req, res) => res.send("HI")) + +app.post("/api/subscription", postSubscription) +app.put("/api/subscription/:id/", putSubscription) + +export default app diff --git a/api/data/database.ts b/api/data/database.ts new file mode 100644 index 0000000..4233267 --- /dev/null +++ b/api/data/database.ts @@ -0,0 +1,20 @@ +import { SubscriptionTable } from "./subscription" +import { Pool } from "pg" + +export interface Database { + subscription: SubscriptionTable +} + +import { Kysely, PostgresDialect } from "kysely" +import settings from "./settings" + +const dialect = new PostgresDialect({ + pool: new Pool({ + ...settings, + max: 10, + }), +}) + +export const db = new Kysely({ + dialect, +}) diff --git a/api/data/migrate.ts b/api/data/migrate.ts new file mode 100644 index 0000000..ffb8681 --- /dev/null +++ b/api/data/migrate.ts @@ -0,0 +1,36 @@ +import { promises as fs } from "fs" +import { FileMigrationProvider, Migrator } from "kysely" +import * as path from "path" +import { db } from "./database" + +const migrator = new Migrator({ + db, + provider: new FileMigrationProvider({ + fs, + path, + migrationFolder: path.join(__dirname, "migrations"), + }), +}) + +async function migrateToLatest() { + const { error, results } = await migrator.migrateToLatest() + + results?.forEach((it) => { + if (it.status === "Success") { + console.log(`migration "${it.migrationName}" was executed successfully`) + } else if (it.status === "Error") { + console.error(`failed to execute migration "${it.migrationName}"`) + } + }) + + if (error) { + console.error("failed to migrate") + console.error(error) + } +} + +export async function resetAll() { + while ((await migrator.migrateDown()).error !== undefined) {} +} + +export default migrateToLatest diff --git a/api/data/migrations/1.addSubscriptions.ts b/api/data/migrations/1.addSubscriptions.ts new file mode 100644 index 0000000..b8e67ff --- /dev/null +++ b/api/data/migrations/1.addSubscriptions.ts @@ -0,0 +1,13 @@ +import { Kysely } from "kysely" + +export async function up(db: Kysely) { + await db.schema + .createTable("subscription") + .addColumn("id", "serial", (col) => col.primaryKey()) + .addColumn("subscription", "json") + .execute() +} + +export async function down(db: Kysely) { + await db.schema.dropTable("subscription").execute() +} diff --git a/api/data/settings.ts b/api/data/settings.ts new file mode 100644 index 0000000..fcb5d9e --- /dev/null +++ b/api/data/settings.ts @@ -0,0 +1,7 @@ +export default { + database: process.env.DATABASE ?? "database", + host: "database", + user: "postgres", + password: process.env.POSTGRES_PASSWORD, + port: 5433, +} diff --git a/api/data/subscription.test.ts b/api/data/subscription.test.ts new file mode 100644 index 0000000..f21dc32 --- /dev/null +++ b/api/data/subscription.test.ts @@ -0,0 +1,91 @@ +import { db } from "./database" +import migrateToLatest, { resetAll } from "./migrate" +import { + createSubscription, + getSubscription, + Subscription, + updateSubscription, +} from "./subscription" + +const pushSubscription1 = { + endpoint: "https://updates.push.services.mozilla.com/wpush/v2/aaaaaaa", + expirationTime: null, + keys: { + auth: "adfsadfasdf", + p256dh: "aaaaaaaaaaaa", + }, +} + +const pushSubscription2 = { + endpoint: "https://updates.push.services.mozilla.com/wpush/v2/bbbbbbbb", + expirationTime: null, + keys: { + auth: "whoahauth", + p256dh: "bbbbbbbb", + }, +} + +jest.mock("./settings", () => ({ + port: 5434, + user: "postgres", + password: process.env.POSTGRES_PASSWORD, + database: "test", + host: "localhost", +})) + +describe("subscriptions", () => { + beforeEach(migrateToLatest) + + afterEach(resetAll) + + test("createSubscription", async () => { + const { id } = await createSubscription(pushSubscription1) + + const subscription = (await db + .selectFrom("subscription") + .selectAll() + .where("id", "=", id) + .executeTakeFirst()) as Subscription + + expect(subscription).toEqual({ + id, + subscription: pushSubscription1, + }) + }) + + test("updateSubscription", async () => { + const { id } = (await db + .insertInto("subscription") + .values({ subscription: JSON.stringify(pushSubscription1), id: 50 }) + .returning("id") + .executeTakeFirst())! + + await updateSubscription(pushSubscription2, id) + + const updated = (await db + .selectFrom("subscription") + .selectAll() + .where("id", "=", id) + .executeTakeFirst()) as Subscription + + expect(updated).toEqual({ + id, + subscription: pushSubscription2, + }) + }) + + test("getSubscription", async () => { + const { id } = (await db + .insertInto("subscription") + .values({ subscription: JSON.stringify(pushSubscription1), id: 5000 }) + .returning("id") + .executeTakeFirst())! + + const got = await getSubscription(id) + + expect(got).toEqual({ + subscription: pushSubscription1, + id: 5000, + }) + }) +}) diff --git a/api/data/subscription.ts b/api/data/subscription.ts new file mode 100644 index 0000000..0007445 --- /dev/null +++ b/api/data/subscription.ts @@ -0,0 +1,48 @@ +import { + Generated, + Insertable, + JSONColumnType, + Selectable, + Updateable, +} from "kysely" +import { db } from "./database" + +export async function createSubscription( + subscription: PushSubscriptionJSON +): Promise { + const inserted = await db + .insertInto("subscription") + .values({ subscription: JSON.stringify(subscription) }) + .returningAll() + .executeTakeFirst() + + return inserted! +} + +export async function updateSubscription( + subscription: PushSubscriptionJSON, + id: number +): Promise { + await db + .updateTable("subscription") + .set({ subscription: JSON.stringify(subscription) }) + .where("id", "=", id) + .executeTakeFirst() +} + +export async function getSubscription(id: number) { + return await db + .selectFrom("subscription") + .selectAll() + .where("id", "=", id) + .executeTakeFirst() +} + +export interface SubscriptionTable { + id: Generated + subscription: JSONColumnType +} + +export type Subscription = Selectable +export type NewSubscription = Insertable +export type SubscriptionUpdate = Updateable diff --git a/api/subscribe.ts b/api/subscribe.ts deleted file mode 100644 index 1b4a57e..0000000 --- a/api/subscribe.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Response, Request } from "express" - -export function subscribe(req: Request, res: Response) { - res.status(200).send() -} diff --git a/api/subscriptionEndpoints.test.ts b/api/subscriptionEndpoints.test.ts new file mode 100644 index 0000000..ebbc167 --- /dev/null +++ b/api/subscriptionEndpoints.test.ts @@ -0,0 +1,47 @@ +import request from "supertest" +import app from "./app" +import { createSubscription, updateSubscription } from "./data/subscription" + +const pushSubscription = { + endpoint: "https://updates.push.services.mozilla.com/wpush/v2/aaaaaaa", + expirationTime: null, + keys: { + auth: "adfsadfasdf", + p256dh: "aaaaaaaaaaaa", + }, +} + +jest.mock("./data/subscription") + +const mockedCreateSubscription = jest.mocked(createSubscription) +const mockedUpdateSubscription = jest.mocked(updateSubscription) + +describe("/api/subscription", () => { + beforeEach(() => { + mockedCreateSubscription.mockImplementation((subscription) => + Promise.resolve({ id: 40, subscription }) + ) + + mockedUpdateSubscription.mockImplementation((subscription, id) => + Promise.resolve({ id, subscription }) + ) + }) + + test("POST", async () => { + const response = await request(app) + .post("/api/subscription/") + .send(pushSubscription) + + expect(JSON.parse(response.text)).toEqual({ + subscriptionId: 40, + }) + + expect(mockedCreateSubscription).toHaveBeenCalledWith(pushSubscription) + }) + + test("PUT", async () => { + await request(app).put("/api/subscription/10/").send(pushSubscription) + + expect(mockedUpdateSubscription).toHaveBeenCalledWith(pushSubscription, 10) + }) +}) diff --git a/api/subscriptionEndpoints/postSubscription.ts b/api/subscriptionEndpoints/postSubscription.ts new file mode 100644 index 0000000..5154802 --- /dev/null +++ b/api/subscriptionEndpoints/postSubscription.ts @@ -0,0 +1,11 @@ +import type { Response, Request } from "express" + +import { createSubscription } from "../data/subscription" + +export async function postSubscription(req: Request, res: Response) { + const subscriptionBody = req.body + + const { id: subscriptionId } = await createSubscription(subscriptionBody) + + res.status(200).send({ subscriptionId }) +} diff --git a/api/subscriptionEndpoints/putSubscription.ts b/api/subscriptionEndpoints/putSubscription.ts new file mode 100644 index 0000000..98c7d63 --- /dev/null +++ b/api/subscriptionEndpoints/putSubscription.ts @@ -0,0 +1,10 @@ +import type { Response, Request } from "express" +import { updateSubscription } from "../data/subscription" + +export async function putSubscription(req: Request, res: Response) { + const subscriptionBody = req.body + + await updateSubscription(subscriptionBody, Number.parseInt(req.params.id)) + + res.status(200).send() +} diff --git a/app/entry.worker.ts b/app/entry.worker.ts index 10fa180..0df099a 100644 --- a/app/entry.worker.ts +++ b/app/entry.worker.ts @@ -4,14 +4,6 @@ export {} declare let self: ServiceWorkerGlobalScope -self.addEventListener("install", (event) => { - console.log("Service worker installed") +import initWorker from "./worker" - event.waitUntil(self.skipWaiting()) -}) - -self.addEventListener("activate", (event) => { - console.log("Service worker activated") - - event.waitUntil(self.clients.claim()) -}) +initWorker(self) diff --git a/app/install/EnableNotifications.tsx b/app/install/EnableNotifications.tsx index 3527d3f..24a792e 100644 --- a/app/install/EnableNotifications.tsx +++ b/app/install/EnableNotifications.tsx @@ -1,7 +1,8 @@ import React from "react" import { useEffect } from "react" import { usePush } from "../usePush" -import { request } from "http" +import pushPublicKey from "../../pushPublicKey" +import urlB64ToUint8Array from "../../b64ToUInt8" export default function EnableNotifications({ onSubscribe, @@ -30,33 +31,8 @@ function EnableButton({ onSubscribe }: { onSubscribe: () => void }) { useEffect(() => { if (!canSendPush) return - subscribeToPush( - urlB64ToUint8Array(applicationServerPublicKey) as any, - (subscription) => { - fetch("/api/subscribe", { - method: "POST", - body: JSON.stringify(subscription), - }) - onSubscribe() - } - ) + subscribeToPush(urlB64ToUint8Array(pushPublicKey) as any, onSubscribe) }, [canSendPush]) return } - -const applicationServerPublicKey = - "BDTbzdtzJxwV0sscdsXla-GKvlcxqQr7edEfkX8-papwvvV1UVc3IMyRacl1BbgTi31nWPji2wKCZkjf1l5iX7Y" - -function urlB64ToUint8Array(base64String: string) { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4) - const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/") - - const rawData = window.atob(base64) - const outputArray = new Uint8Array(rawData.length) - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i) - } - return outputArray -} diff --git a/app/routes/_index.test.tsx b/app/routes/_index.test.tsx deleted file mode 100644 index a9ed4f7..0000000 --- a/app/routes/_index.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { act, render, screen, waitFor } from "@testing-library/react" -import MatchMediaMock from "jest-matchmedia-mock" - -import { createRemixStub, RemixStubProps } from "@remix-run/testing" -import App from "../root" -import React from "react" -import Index from "./_index" -let RemixStub: (props: RemixStubProps) => React.JSX.Element - -describe("root", () => { - beforeEach(() => { - RemixStub = createRemixStub([ - { - path: "/", - meta: () => [], - links: () => [], - loader: () => ({ - isSupported: true, - isMobileSafari: true, - isIOS: true, - }), - Component: Index, - }, - ]) - }) - - let matchMedia: MatchMediaMock - - beforeAll(() => { - matchMedia = new MatchMediaMock() - }) - - afterEach(() => { - matchMedia.clear() - }) - - describe("when user is on iOS", () => { - describe("version 16.4 or higher", () => { - describe("and tack up now is not already installed on their device", () => { - test("they are instructed to install tack up now", async () => { - render() - - await waitFor( - () => screen.findByText(/Install Tack Up Now!/), - { - timeout: 2000, - } - ) - - expect(true).toBe(true) - }) - }) - }) - }) -}) diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index 39f7b45..5beb513 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -4,13 +4,13 @@ import { type MetaFunction, } from "@remix-run/node" import { useLoaderData } from "@remix-run/react" -import React, { Suspense, useEffect, useState } from "react" +import React, { useEffect, useRef, useState } from "react" import coerceSemver from "semver/functions/coerce" import versionAtLeast from "semver/functions/gte" import UAParser from "ua-parser-js" +import ClientOnly from "../ClientOnly" import InstallPrompts from "../install/InstallPrompts" import useInstallState from "../useInstallState" -import ClientOnly from "../ClientOnly" export const meta: MetaFunction = () => { return [ @@ -30,11 +30,27 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const isIOS = os.name === "iOS" - return json({ - isSupported, - isMobileSafari, - isIOS, - }) + const cookies = parseCookies(request.headers.get("Cookie")) + const userIdCookie = cookies?.find(({ key }) => key === "userId") + + const newUserId = + userIdCookie === undefined ? Math.floor(Math.random()) * 5 : null + + return json( + { + isSupported, + isMobileSafari, + isIOS, + }, + { + headers: + newUserId !== null && isSupported && isIOS && isMobileSafari + ? { + "Set-Cookie": `userId=${newUserId}`, + } + : {}, + } + ) } export default function Index() { @@ -42,7 +58,7 @@ export default function Index() { return (
- { + const [key, value] = string.trim().split("=") + + return { key, value } + }) + + return cookies +} + +function TackUpNow({ isSupported, isMobileSafari, isIOS, diff --git a/app/usePush.ts b/app/usePush.ts index d08dd9b..7c02b8f 100644 --- a/app/usePush.ts +++ b/app/usePush.ts @@ -131,8 +131,6 @@ export const usePush = (): PushObject => { } catch (err) { console.error("Error getting service worker registration:", err) } - } else { - console.warn("Service Workers are not supported in this browser.") } } diff --git a/app/worker.test.ts b/app/worker.test.ts new file mode 100644 index 0000000..4cce979 --- /dev/null +++ b/app/worker.test.ts @@ -0,0 +1,211 @@ +import "fake-indexeddb/auto" +import "core-js/stable/structured-clone" +import initWorker from "./worker" +import Dexie, { EntityTable } from "dexie" + +const createSelf = () => + ({ + addEventListener: jest.fn(), + skipWaiting: jest.fn(() => Promise.resolve()), + registration: { + pushManager: { + subscribe: jest.fn(() => Promise.resolve({ subscription: "HI" })), + getSubscription: jest.fn(() => Promise.resolve({ subscription: "HI" })), + }, + }, + clients: { + claim: jest.fn(() => Promise.resolve()), + }, + }) as unknown as ServiceWorkerGlobalScope + +describe("service worker", () => { + let self: ServiceWorkerGlobalScope + + beforeEach(() => { + indexedDB = new IDBFactory() + self = createSelf() + + // fetchMock.mockIf(/http:\/\/localhost\/api\/subscribe\/?/, (req) => { + // return Promise.resolve( + // req.method === "POST" + // ? { + // body: JSON.stringify({ subscriptionId: 123 }), + // } + // : req.method === "PUT" + // ? { init: { status: 200 } } + // : { init: { status: 405 } } + // ) + // }) + + fetchMock.mockResponse((req) => { + return Promise.resolve( + req.method === "POST" + ? { + body: JSON.stringify({ subscriptionId: 123 }), + } + : req.method === "PUT" + ? { init: { status: 200 } } + : { init: { status: 405 } } + ) + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + xtest("displays push notifications", () => { + initWorker(self) + + const pushHandler = getHandler("push") + + const pushEvent = createEvent({ data: "HI" }) + pushHandler(pushEvent) + }) + + test("claims on activate", async () => { + initWorker(self) + + const activateHandler = getHandler("activate") + + const event = createEvent() + activateHandler(event) + await waitUntilCalls(event) + + expect(self.clients.claim).toHaveBeenCalled() + }) + + test("skips waiting on install", async () => { + initWorker(self) + + const installHandler = getHandler("install") + + const event = createEvent() + installHandler(event) + await waitUntilCalls(event) + + expect(self.skipWaiting).toHaveBeenCalled() + }) + + test("puts push subscription if the subscription id is set", async () => { + initWorker(self) + await setSavedSubscription(5000) + + const changeHandler = getHandler("pushsubscriptionchange") + + const changeEvent = createEvent() + changeHandler(changeEvent) + await waitUntilCalls(changeEvent) + + expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: expect.any(Uint8Array), + }) + + expect(fetch).toHaveBeenCalledWith( + `${process.env.BASE_URL}/api/subscription/5000`, + { + method: "PUT", + body: JSON.stringify({ subscription: "HI" }), + } + ) + }) + + test("posts to subscriptions when there is no user set", async () => { + initWorker(self) + + const changeHandler = getHandler("pushsubscriptionchange") + + const changeEvent = createEvent() + changeHandler(changeEvent) + await waitUntilCalls(changeEvent) + + expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + applicationServerKey: expect.any(Uint8Array), + }) + + expect(fetch).toHaveBeenCalledWith( + `${process.env.BASE_URL}/api/subscription/`, + { + method: "POST", + body: JSON.stringify({ subscription: "HI" }), + } + ) + }) + + test("subscription id is saved on first subscription change", async () => { + initWorker(self) + + const changeHandler = getHandler("pushsubscriptionchange") + + const changeEvent = createEvent() + changeHandler(changeEvent) + await waitUntilCalls(changeEvent) + + expect(await getSavedSubscription()).toEqual({ id: 1, subscriptionId: 123 }) + }) + + function getHandler(eventName: string) { + expect(self.addEventListener).toHaveBeenCalledWith( + eventName, + expect.anything() + ) + + const [, handler] = (self.addEventListener as jest.Mock).mock.calls.find( + ([event]: [string]) => event === eventName + ) + + return handler + } +}) + +async function getSavedSubscription() { + const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & { + subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id"> + } + + database.version(1).stores({ subscriptions: "id++, subscriptionId&" }) + + return (await database.subscriptions.toArray())[0] +} + +async function setSavedSubscription(subscriptionId: number) { + const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & { + subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id"> + } + + database.version(1).stores({ subscriptions: "id++, subscriptionId&" }) + + await database.subscriptions.put({ id: 1, subscriptionId }) +} + +function createPushEvent(data: string) { + const pushFields = { + data: { + blob: () => new Blob([data], { type: "text/plain" }), + json: () => JSON.parse(data), + text: () => data, + arrayBuffer: () => new TextEncoder().encode(data), + }, + } + + return createEvent(pushFields) +} + +function createMessageEvent(message: any) { + return createEvent({ data: message }) +} + +function createEvent(properties?: object) { + return { + waitUntil: jest.fn(), + ...properties, + } +} + +function waitUntilCalls(event: { + waitUntil: jest.Mock> +}) { + return Promise.all(event.waitUntil.mock.calls.map(([promise]) => promise)) +} diff --git a/app/worker.ts b/app/worker.ts new file mode 100644 index 0000000..a90fa1f --- /dev/null +++ b/app/worker.ts @@ -0,0 +1,101 @@ +import Dexie, { EntityTable } from "dexie" +import urlB64ToUint8Array from "../b64ToUInt8" +import pushPublicKey from "../pushPublicKey" + +interface SubscriptionRecord { + id: number + subscriptionId: number +} + +export default function start(self: ServiceWorkerGlobalScope) { + self.addEventListener("install", (event) => { + event.waitUntil(self.skipWaiting()) + }) + + self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()) + }) + + // self.addEventListener("push", function (event: PushEvent) { + // console.log("[Service Worker] Push Received.") + // console.log(`[Service Worker] Push had this data: "${event.data?.text()}"`) + + // const title = "Push Codelab" + // const options = { + // body: "Yay it works.", + // icon: "images/icon.png", + // badge: "images/badge.png", + // } + + // event.waitUntil(self.registration.showNotification(title, options)) + // }) + + // self.addEventListener("notificationclick", function (event) { + // console.log("[Service Worker] Notification click Received.") + + // event.notification.close() + + // event.waitUntil( + // self.clients.openWindow("https://developers.google.com/web/") + // ) + // }) + + self.addEventListener("pushsubscriptionchange", function (event) { + const waitEvent = event as ExtendableEvent + + waitEvent.waitUntil(submitSubscription(self.registration)) + + self.registration.pushManager.getSubscription() + }) +} + +async function submitSubscription(registration: ServiceWorkerRegistration) { + const db = database() + + const subscription = await registration.pushManager.getSubscription() + if (subscription === null) return + + const existingSubscriptionId = await db.subscriptions.get(1) + + const applicationServerKey = urlB64ToUint8Array(pushPublicKey) + const newSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey, + }) + + await (existingSubscriptionId === undefined + ? postSubscription(newSubscription, db) + : putSubscription(newSubscription, existingSubscriptionId.subscriptionId)) +} + +async function postSubscription( + subscription: PushSubscription, + db: ReturnType +) { + const response = await fetch(`${process.env.BASE_URL}/api/subscription/`, { + method: "POST", + body: JSON.stringify(subscription), + }) + + db.subscriptions.put({ + id: 1, + subscriptionId: (await response.json()).subscriptionId, + }) +} + +function putSubscription(subscription: PushSubscription, id: number) { + return fetch(`${process.env.BASE_URL}/api/subscription/${id}`, { + method: "PUT", + body: JSON.stringify(subscription), + }) +} + +function database() { + const db = new Dexie("tack-up-now", { indexedDB }) as Dexie & { + subscriptions: EntityTable + } + + db.version(1).stores({ subscriptions: "id++, subscriptionId&" }) + + return db +} diff --git a/b64ToUInt8.ts b/b64ToUInt8.ts new file mode 100644 index 0000000..100edc9 --- /dev/null +++ b/b64ToUInt8.ts @@ -0,0 +1,12 @@ +export default function urlB64ToUint8Array(base64String: string) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/") + + const rawData = window.atob(base64) + const outputArray = new Uint8Array(rawData.length) + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + return outputArray +} diff --git a/docker-compose-test.yaml b/docker-compose-test.yaml new file mode 100644 index 0000000..673de0f --- /dev/null +++ b/docker-compose-test.yaml @@ -0,0 +1,11 @@ +services: + database: + image: postgres + ports: + - "5434:5432" + environment: + POSTGRES_PASSWORD: testpassword + POSTGRES_USER: postgres + POSTGRES_DB: test + volumes: + - ./data-test:/var/lib/postgresql/data diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts index ba65380..e4a8db6 100644 --- a/e2e/install.spec.ts +++ b/e2e/install.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from "@playwright/test" +import matchPath from "./urlMatcher" const { describe, beforeEach, skip, use } = test @@ -74,21 +75,6 @@ describe("when user is on iOS", () => { permission: "default", requestPermissionResult: "granted", }) - await page.route("/api/subscribe", async (route) => { - await route.fulfill() - }) - }) - - test("their push token is submitted after notifications are enabled", async ({ - page, - }) => { - await page.goto("/") - - const requestPromise = page.waitForRequest("/api/subscribe") - await page.getByText(/Enable Notifications/).click() - const request = await requestPromise - - await expect(request.postDataJSON()).toEqual({ hi: "subscription" }) }) test("users see tack up now after enabling notifications", async ({ @@ -96,9 +82,7 @@ describe("when user is on iOS", () => { }) => { await page.goto("/") - const requestPromise = page.waitForRequest("/api/subscribe") await page.getByText(/Enable Notifications/).click() - await requestPromise const yourNotificationsHeading = page.getByText(/Your Notifications/) @@ -164,7 +148,7 @@ describe("other browsers", () => { nonWebkitOnly() beforeEach(async ({ page }) => { - await page.route("/api/subscribe", async (route) => { + await page.route(matchPath(page, "/api/subscription"), async (route) => { await route.fulfill() }) await stubServiceWorker(page) @@ -188,23 +172,6 @@ describe("other browsers", () => { await expect(yourNotificationsHeading).toBeVisible() }) - test("submit the push subscription on permission granted", async ({ - page, - }) => { - await stubNotifications(page, { - permission: "default", - requestPermissionResult: "granted", - }) - - await page.goto("/") - - const requestPromise = page.waitForRequest("/api/subscribe") - await page.getByText(/Enable Notifications/).click() - const request = await requestPromise - - await expect(request.postDataJSON()).toEqual({ hi: "subscription" }) - }) - test("prompt to allow notifications if permission was denied", async ({ page, }) => { @@ -245,6 +212,8 @@ function stubNotifications( function stubServiceWorker(page: Page) { return page.addInitScript(() => { + const serviceWorker = {} + const registration = { pushManager: { getSubscription() { @@ -254,10 +223,12 @@ function stubServiceWorker(page: Page) { return Promise.resolve({ hi: "subscription" }) }, }, + active: serviceWorker, } Object.defineProperty(navigator, "serviceWorker", { value: { + ready: Promise.resolve(registration), register() { return Promise.resolve(registration) }, diff --git a/e2e/urlMatcher.ts b/e2e/urlMatcher.ts new file mode 100644 index 0000000..4595bff --- /dev/null +++ b/e2e/urlMatcher.ts @@ -0,0 +1,6 @@ +import { Page, Request } from "@playwright/test" + +export default function matchPath(page: Page, path: string) { + return (url: URL | Request) => + new URL(page.url()).hostname === url.hostname && url.pathname === path +} diff --git a/jest.config.ts b/jest.config.ts index 0650c6f..96aa2ed 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -4,7 +4,8 @@ const ignorePatterns = [ "\\/\\.vscode\\/", "\\/\\.tmp\\/", "\\/\\.cache\\/", - "data", + "^\\/data/", + "^\\/data-test/", "e2e", ] diff --git a/package-lock.json b/package-lock.json index e0c8b51..ae75e1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "packages": { "": { "name": "site", - "hasInstallScript": true, "dependencies": { "@remix-pwa/sw": "^3.0.9", "@remix-pwa/worker-runtime": "^2.1.4", @@ -14,12 +13,16 @@ "@remix-run/react": "^2.9.0", "@remix-run/serve": "^2.9.0", "cross-env": "^7.0.3", + "dexie": "^4.0.8", "express": "^4.19.2", "isbot": "^4.1.0", + "kysely": "^0.27.4", + "pg": "^8.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", "semver": "^7.6.3", - "ua-parser-js": "^1.0.39" + "ua-parser-js": "^1.0.39", + "web-push": "^3.6.7" }, "devDependencies": { "@babel/core": "^7.24.5", @@ -39,6 +42,7 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^20.14.5", + "@types/pg": "^8.11.10", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@types/semver": "^7.5.8", @@ -48,14 +52,17 @@ "@typescript-eslint/parser": "^6.7.4", "babel-jest": "^29.7.0", "babel-plugin-transform-remove-console": "^6.9.4", + "core-js": "^3.38.1", "eslint": "^8.38.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.28.1", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", + "fake-indexeddb": "^6.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "jest-matchmedia-mock": "^1.1.0", "jest-watch-typeahead": "^2.2.2", "prettier": "^3.3.3", @@ -5311,6 +5318,74 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/pg": { + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -6080,6 +6155,17 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -6367,6 +6453,11 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", + "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -6501,6 +6592,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7029,6 +7125,17 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/core-js": { + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz", + "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.37.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.1.tgz", @@ -7091,6 +7198,15 @@ "yarn": ">=1" } }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7408,6 +7524,11 @@ "node": ">=8" } }, + "node_modules/dexie": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz", + "integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -7532,6 +7653,14 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8671,6 +8800,15 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fake-indexeddb": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.0.0.tgz", + "integrity": "sha512-YEboHE5VfopUclOck7LncgIqskAqnv4q0EWbYCaxKKjAvO93c+TJIaBuGy8CBFdbg9nKdpN3AuPRwVBJ4k7NrQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9374,6 +9512,14 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -10643,6 +10789,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -11502,6 +11658,25 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11519,6 +11694,14 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.4.tgz", + "integrity": "sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", @@ -12604,6 +12787,11 @@ "node": ">=4" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "node_modules/minimatch": { "version": "9.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", @@ -12866,6 +13054,48 @@ "node": ">= 0.6" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -13087,6 +13317,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -13406,6 +13642,96 @@ "is-reference": "^3.0.0" } }, + "node_modules/pg": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", @@ -13730,6 +14056,47 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dev": true + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13818,6 +14185,12 @@ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==" }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -14832,6 +15205,14 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -16709,6 +17090,47 @@ "@zxing/text-encoding": "0.9.0" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index ceccee4..41c61f6 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "start": "remix-serve ./build/server/index.js", "typecheck": "tsc", - "watch": "jest --watch --config=jest.config.ts", - "test": "jest --config=jest.config.ts" + "watch": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d && jest --watch --config=jest.config.ts", + "test": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d && jest --config=jest.config.ts" }, "dependencies": { "@remix-pwa/sw": "^3.0.9", @@ -20,12 +20,16 @@ "@remix-run/react": "^2.9.0", "@remix-run/serve": "^2.9.0", "cross-env": "^7.0.3", + "dexie": "^4.0.8", "express": "^4.19.2", "isbot": "^4.1.0", + "kysely": "^0.27.4", + "pg": "^8.13.0", "react": "^18.2.0", "react-dom": "^18.2.0", "semver": "^7.6.3", - "ua-parser-js": "^1.0.39" + "ua-parser-js": "^1.0.39", + "web-push": "^3.6.7" }, "devDependencies": { "@babel/core": "^7.24.5", @@ -45,6 +49,7 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/node": "^20.14.5", + "@types/pg": "^8.11.10", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@types/semver": "^7.5.8", @@ -54,14 +59,17 @@ "@typescript-eslint/parser": "^6.7.4", "babel-jest": "^29.7.0", "babel-plugin-transform-remove-console": "^6.9.4", + "core-js": "^3.38.1", "eslint": "^8.38.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.28.1", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", + "fake-indexeddb": "^6.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "jest-matchmedia-mock": "^1.1.0", "jest-watch-typeahead": "^2.2.2", "prettier": "^3.3.3", diff --git a/pushPublicKey.ts b/pushPublicKey.ts new file mode 100644 index 0000000..39f1b0e --- /dev/null +++ b/pushPublicKey.ts @@ -0,0 +1 @@ +export default "BKc02U2-z7PkTbYgYLlxELWqzTVE631fs4IPuMLbxY_rxdo9VaduthqwkPOiblwjETl99uXes2Nc9EtPbS5x4uA" diff --git a/send-it.js b/send-it.js new file mode 100644 index 0000000..9d55ca0 --- /dev/null +++ b/send-it.js @@ -0,0 +1,22 @@ +import webPush from "web-push" + +const subscription = { + endpoint: + "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABm9FlbY5vMro60hF2iJF3UUBzweuFcg5NRSHXPSpHfUpjo5jKGVRnUxR4ekg0-FsvdQCP89cu__IFd06Tu2TJZ649YyqivRUBnAav0DgOLHGx5-t943QLS-wLvBqyJRCuuLlM1bLz6S9ph9AWJ8CG7rQuTabsHvw--s_w2KDQo3GcQXIM", + expirationTime: null, + keys: { + auth: "dDUqtFo26ekGEAmNzvmJAw", + p256dh: + "BHIT3J6xSRiHfz0m-QRHagDThiOZGVIANtPzOasrOBYG0s_yUnshTVharX5dZcq8GA5OkyMm3mqmA7_o_lFR4WE", + }, +} +webPush.setVapidDetails( + "https://tackupnow.com", + "BKc02U2-z7PkTbYgYLlxELWqzTVE631fs4IPuMLbxY_rxdo9VaduthqwkPOiblwjETl99uXes2Nc9EtPbS5x4uA", + "Xmv5Pc4mqr138V3sCxXq7UmsbL5UgSOY43UuJ50nxPw" +) + +webPush + .sendNotification(subscription, "Hi what's up?", {}) + .then((x) => console.log("It sent", x)) + .catch((error) => console.log("It did not send", error)) diff --git a/server.ts b/server.ts index ce3a4c3..1c03d4f 100644 --- a/server.ts +++ b/server.ts @@ -1,16 +1,11 @@ import { createRequestHandler } from "@remix-run/express" import express from "express" -// notice that the result of `remix vite:build` is "just a module" import * as build from "./build/server/index.js" -import { subscribe } from "./api/subscribe.js" -const app = express() +import app from "./api/app" + app.use(express.static("build/client")) -app.get("/api", (req, res) => res.send("HI")) -app.post("/api/subscribe", subscribe) - -// and your app is "just a request handler" app.all("*", createRequestHandler({ build })) app.listen(3000, () => { diff --git a/setupTests.ts b/setupTests.ts index c5e6107..3260e6c 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -2,15 +2,20 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import React from 'react' -import '@testing-library/jest-dom' +import React from "react" +import "@testing-library/jest-dom" +import { enableFetchMocks } from "jest-fetch-mock" -const JSDOMFormData = global.FormData; +const JSDOMFormData = global.FormData global.React = React -global.TextDecoder = require("util").TextDecoder; -global.TextEncoder = require("util").TextEncoder; -global.ReadableStream = require("stream/web").ReadableStream; -global.WritableStream = require("stream/web").WritableStream; +global.TextDecoder = require("util").TextDecoder +global.TextEncoder = require("util").TextEncoder +global.ReadableStream = require("stream/web").ReadableStream +global.WritableStream = require("stream/web").WritableStream -require("@remix-run/node").installGlobals({ nativeFetch: true }); -global.FormData = JSDOMFormData; \ No newline at end of file +require("@remix-run/node").installGlobals({ nativeFetch: true }) +global.FormData = JSDOMFormData + +process.env.BASE_URL = "http://localhost" + +enableFetchMocks()