iOS install instructions
Test / test (push) Failing after 42s Details

This commit is contained in:
Jeff 2024-09-21 18:21:45 -04:00
parent 16015491e4
commit fd351360f1
14 changed files with 383 additions and 230 deletions

View File

@ -1,6 +1,6 @@
{ {
"trailingComma": "es5", "trailingComma": "es5",
"tabWidth": 4, "tabWidth": 2,
"semi": false, "semi": false,
"singleQuote": false, "singleQuote": false,
"overrides": [ "overrides": [

54
.swcrc
View File

@ -1,31 +1,31 @@
{ {
"jsc": { "jsc": {
"target": "es2022", "target": "es2022",
"parser": { "parser": {
"syntax": "typescript", "syntax": "typescript",
"tsx": true, "tsx": true,
"decorators": false, "decorators": false,
"dynamicImport": false "dynamicImport": false
},
"transform": {
"react": {
"pragma": "React.createElement",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": false,
"runtime": "automatic"
},
"hidden": {
"jest": true
}
}
}, },
"module": { "transform": {
"type": "commonjs", "react": {
"strict": false, "pragma": "React.createElement",
"strictMode": true, "pragmaFrag": "React.Fragment",
"lazy": false, "throwIfNamespace": true,
"noInterop": false "development": false,
"useBuiltins": false,
"runtime": "automatic"
},
"hidden": {
"jest": true
}
} }
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
} }

View File

@ -4,9 +4,10 @@
* For more information, see https://remix.run/file-conventions/entry.client * For more information, see https://remix.run/file-conventions/entry.client
*/ */
import { RemixBrowser } from "@remix-run/react"; import { RemixBrowser } from "@remix-run/react"
import { startTransition, StrictMode } from "react"; import React from "react"
import { hydrateRoot } from "react-dom/client"; import { startTransition, StrictMode } from "react"
import { hydrateRoot } from "react-dom/client"
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
@ -14,5 +15,5 @@ startTransition(() => {
<StrictMode> <StrictMode>
<RemixBrowser /> <RemixBrowser />
</StrictMode> </StrictMode>
); )
}); })

View File

@ -4,16 +4,16 @@
* For more information, see https://remix.run/file-conventions/entry.server * For more information, see https://remix.run/file-conventions/entry.server
*/ */
import { PassThrough } from "node:stream"; import { PassThrough } from "node:stream"
import type { AppLoadContext, EntryContext } from "@remix-run/node"; import type { AppLoadContext, EntryContext } from "@remix-run/node"
import { createReadableStreamFromReadable } from "@remix-run/node"; import { createReadableStreamFromReadable } from "@remix-run/node"
import { RemixServer } from "@remix-run/react"; import { RemixServer } from "@remix-run/react"
import { isbot } from "isbot"; import { isbot } from "isbot"
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"
import React from "react"; import React from "react"
const ABORT_DELAY = 5_000; const ABORT_DELAY = 5_000
export default function handleRequest( export default function handleRequest(
request: Request, request: Request,
@ -37,7 +37,7 @@ export default function handleRequest(
responseStatusCode, responseStatusCode,
responseHeaders, responseHeaders,
remixContext remixContext
); )
} }
function handleBotRequest( function handleBotRequest(
@ -47,7 +47,7 @@ function handleBotRequest(
remixContext: EntryContext remixContext: EntryContext
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<RemixServer <RemixServer
context={remixContext} context={remixContext}
@ -56,38 +56,38 @@ function handleBotRequest(
/>, />,
{ {
onAllReady() { onAllReady() {
shellRendered = true; shellRendered = true
const body = new PassThrough(); const body = new PassThrough()
const stream = createReadableStreamFromReadable(body); const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html"); responseHeaders.set("Content-Type", "text/html")
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}) })
); )
pipe(body); pipe(body)
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
reject(error); reject(error)
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error); console.error(error)
} }
}, },
} }
); )
setTimeout(abort, ABORT_DELAY); setTimeout(abort, ABORT_DELAY)
}); })
} }
function handleBrowserRequest( function handleBrowserRequest(
@ -97,7 +97,7 @@ function handleBrowserRequest(
remixContext: EntryContext remixContext: EntryContext
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<RemixServer <RemixServer
context={remixContext} context={remixContext}
@ -106,36 +106,36 @@ function handleBrowserRequest(
/>, />,
{ {
onShellReady() { onShellReady() {
shellRendered = true; shellRendered = true
const body = new PassThrough(); const body = new PassThrough()
const stream = createReadableStreamFromReadable(body); const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html"); responseHeaders.set("Content-Type", "text/html")
resolve( resolve(
new Response(stream, { new Response(stream, {
headers: responseHeaders, headers: responseHeaders,
status: responseStatusCode, status: responseStatusCode,
}) })
); )
pipe(body); pipe(body)
}, },
onShellError(error: unknown) { onShellError(error: unknown) {
reject(error); reject(error)
}, },
onError(error: unknown) { onError(error: unknown) {
responseStatusCode = 500; responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log // Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll // errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest. // reject and get logged in handleDocumentRequest.
if (shellRendered) { if (shellRendered) {
console.error(error); console.error(error)
} }
}, },
} }
); )
setTimeout(abort, ABORT_DELAY); setTimeout(abort, ABORT_DELAY)
}); })
} }

View File

@ -1,17 +1,17 @@
/// <reference lib="WebWorker" /> /// <reference lib="WebWorker" />
export {}; export {}
declare let self: ServiceWorkerGlobalScope; declare let self: ServiceWorkerGlobalScope
self.addEventListener('install', event => { self.addEventListener("install", (event) => {
console.log('Service worker installed'); console.log("Service worker installed")
event.waitUntil(self.skipWaiting()); event.waitUntil(self.skipWaiting())
}); })
self.addEventListener('activate', event => { self.addEventListener("activate", (event) => {
console.log('Service worker activated'); console.log("Service worker activated")
event.waitUntil(self.clients.claim()); event.waitUntil(self.clients.claim())
}); })

View File

@ -1,45 +1,47 @@
import { act, render, screen, waitFor } from '@testing-library/react' import { act, render, screen, waitFor } from "@testing-library/react"
import MatchMediaMock from 'jest-matchmedia-mock'; import MatchMediaMock from "jest-matchmedia-mock"
import { createRemixStub, RemixStubProps } from "@remix-run/testing" import { createRemixStub, RemixStubProps } from "@remix-run/testing"
import App from './root' import App from "./root"
import React from 'react' import React from "react"
let RemixStub: (props: RemixStubProps) => React.JSX.Element let RemixStub: (props: RemixStubProps) => React.JSX.Element
describe("root", () => { describe("root", () => {
beforeEach(() => { beforeEach(() => {
RemixStub = createRemixStub([ RemixStub = createRemixStub([
{ {
path: "/", path: "/",
meta: () => ([]), meta: () => [],
links: () => ([]), links: () => [],
loader: () => ({ isSupported: true}), loader: () => ({ isSupported: true }),
Component: App Component: App,
} },
]) ])
}) })
let matchMedia: MatchMediaMock let matchMedia: MatchMediaMock
beforeAll(() => { beforeAll(() => {
matchMedia = new MatchMediaMock(); matchMedia = new MatchMediaMock()
}) })
afterEach(() => {
matchMedia.clear();
})
describe("when user is on iOS", () => { afterEach(() => {
describe("version 16.4 or higher", () => { matchMedia.clear()
describe("and tack up now is not already installed on their device", () => { })
test("they are instructed to install tack up now", async () => {
render(<RemixStub/>)
await waitFor(() => screen.findByText<HTMLElement>(/Install/), { timeout: 2000 }) 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(<RemixStub />)
expect(true).toBe(true) await waitFor(() => screen.findByText<HTMLElement>(/Install/), {
}) timeout: 2000,
}) })
expect(true).toBe(true)
}) })
})
}) })
})
}) })

View File

@ -1,33 +1,30 @@
import { import {
Links, Links,
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from "@remix-run/react" } from "@remix-run/react"
import React from "react" import React from "react"
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1" />
name="viewport" <Meta />
content="width=device-width, initial-scale=1" <Links />
/> </head>
<Meta /> <body>
<Links /> {children}
</head> <ScrollRestoration />
<body> <Scripts />
{children} </body>
<ScrollRestoration /> </html>
<Scripts /> )
</body>
</html>
)
} }
export default function App() { export default function App() {
return <Outlet/> return <Outlet />
} }

View File

@ -1,52 +1,82 @@
import { json, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node"; import {
import { useLoaderData } from "@remix-run/react"; json,
import React, { Suspense, useEffect } from "react"; type LoaderFunctionArgs,
import UAParser from "ua-parser-js" type MetaFunction,
import versionAtLeast from "semver/functions/gte" } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import React, { useState } from "react"
import coerceSemver from "semver/functions/coerce" import coerceSemver from "semver/functions/coerce"
import versionAtLeast from "semver/functions/gte"
import UAParser from "ua-parser-js"
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook"
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [ return [
{ title: "Tack Up Now!" }, { title: "Tack Up Now!" },
{ name: "description", content: "Get equinelive notifications" }, { name: "description", content: "Get equinelive notifications" },
]; ]
}; }
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
const userAgent = request.headers.get("user-agent") const userAgent = request.headers.get("user-agent")
const os = new UAParser(userAgent ?? "").getOS() const os = new UAParser(userAgent ?? "").getOS()
const isSupported = const isSupported =
os.name !== "iOS" || os.name !== "iOS" ||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0") versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
return json({ isSupported }) return json({ isSupported })
} }
export default function Index() { export default function Index() {
const { isSupported } = useLoaderData<typeof loader>() const { isSupported } = useLoaderData<typeof loader>()
return ( return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<Suspense> <LandingMessage isSupported={isSupported} />
<LandingMessage isSupported={isSupported}/>
</Suspense>
</div> </div>
); )
} }
function LandingMessage({ isSupported }: { isSupported: boolean }) { function LandingMessage({ isSupported }: { isSupported: boolean }) {
const isClient = typeof window !== "undefined"
const notificationsEnabled =
isClient &&
"Notification" in window &&
window.Notification.permission === "granted"
if (typeof window === "undefined") return <div>Loading</div> const isRunningPWA =
isClient &&
(("standalone" in navigator && (navigator.standalone as boolean)) ||
matchMedia("(dislay-mode: standalone)").matches)
const isRunningPWA = ("standalone" in navigator && navigator.standalone as boolean) || matchMedia("(dislay-mode: standalone)").matches const [isInstalled, setIsInstalled] = useState(notificationsEnabled)
const notificationsEnabled = "Notification" in window && window.Notification.permission === "granted"
const message = isRunningPWA && !notificationsEnabled
? "Enable notifications"
: isSupported
? "Install Tack Up Now!"
: "Sorry, your device doesn't support Tack Up Now! :("
return <div><div>{message}</div></div> return !isClient ? (
<div>Loading</div>
) : isInstalled ? (
<div>Your Notifications</div>
) : isRunningPWA && !notificationsEnabled ? (
<EnableButton onSubscribe={() => setIsInstalled(true)} />
) : isSupported ? (
"Install Tack Up Now!"
) : (
"Sorry, your device doesn't support Tack Up Now! :("
)
}
function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
const { subscribeToPush, requestPermission } = usePush()
return <button onClick={subscribe}>Enable notifications</button>
async function subscribe() {
console.log("Hey the thing was clicked wow")
subscribeToPush("Derpderp", (subscription) => {
fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
})
onSubscribe()
})
}
} }

View File

@ -1,76 +1,186 @@
import { test, expect } from "@playwright/test" import { test, expect, Page } from "@playwright/test"
import { before } from "node:test"
const { describe, beforeEach, skip, use } = test const { describe, beforeEach, skip, use } = test
function webkitOnly() { function webkitOnly() {
beforeEach(async ({ browserName }) => { beforeEach(async ({ browserName }) => {
if (browserName !== "webkit") skip() if (browserName !== "webkit") skip()
}) })
} }
describe("when user is on iOS", () => { describe("when user is on iOS", () => {
webkitOnly() webkitOnly()
describe("version 16.4 or higher", () => { describe("version 16.4 or higher", () => {
use({ use({
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", userAgent:
}) "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({ page }) => {
await page.goto("/")
const installText = await page.getByText(/Install Tack Up Now!/)
await expect(installText).toBeAttached()
})
describe("and tack up now is running as a PWA", () => {
beforeEach(async ({ page }) => {
await page.addInitScript(() => (window.navigator as any)["standalone"] = true)
})
test("and notifications aren't enabled, they are asked to enable notifications", async ({ page, browser }) => {
await page.addInitScript(() => (Object.defineProperty(window.Notification, "permission", {value: "default", writable: true })))
await page.goto("/")
const notificationText = await page.getByText(/Enable notifications/)
await expect(notificationText).toBeAttached()
})
describe("and notifications are enabled", () => {
beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.Notification = window.Notification ?? {}
Object.defineProperty(window.Notification, "permission", {value: "granted", writable: true })
})
})
test("they aren't asked to enable notifications", async ({ page }) => {
await page.goto("/")
await page.evaluate(async () => await navigator.serviceWorker.ready)
const notificationText = await page.getByText(/Enable notifications/)
await expect(notificationText).not.toBeAttached()
})
})
})
}) })
describe("version 16.3 and under", () => { test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({
use({ page,
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1", }) => {
await page.goto("/")
const installText = await page.getByText(/Install Tack Up Now!/)
await expect(installText).toBeAttached()
})
describe("and tack up now is running as a PWA", () => {
beforeEach(async ({ page }) => {
await page.addInitScript(
() => ((window.navigator as any)["standalone"] = true)
)
await stubServiceWorker(page)
})
describe("and notifications aren't enabled", () => {
test("they are asked to enable notifications", async ({ page }) => {
await stubNotifications(page, { permission: "default" })
await page.goto("/")
const enableButton = await page.getByText(/Enable notifications/)
await expect(enableButton).toBeAttached()
}) })
test("version 16.3 and under they are informed that their iOS version isn't supported", async ({ page }) => { describe("and then the user enables notifications", () => {
beforeEach(async ({ page }) => {
await stubNotifications(page, {
permission: "default",
requestPermissionResult: "granted",
})
await page.route("/subscribe", async (route) => {
await route.fulfill()
})
})
test("their push token is submitted after notifications are enabled", async ({
page,
}) => {
await page.goto("/") await page.goto("/")
const sorryText = await page.getByText(/Sorry/) const requestPromise = page.waitForRequest("/subscribe")
await page.getByText(/Enable notifications/).click()
const request = await requestPromise
await expect(sorryText).toBeVisible() await expect(request.postDataJSON()).toEqual({ hi: "subscription" })
})
test("users see tack up now after enabling notifications", async ({
page,
}) => {
await page.goto("/")
const requestPromise = page.waitForRequest("/subscribe")
await page.getByText(/Enable notifications/).click()
await requestPromise
const yourNotificationsHeading =
await page.getByText(/Your Notifications/)
await expect(yourNotificationsHeading).toBeVisible()
})
}) })
})
describe("and notifications are enabled", () => {
beforeEach(async ({ page }) => {
await stubNotifications(page, { permission: "granted" })
})
test("they aren't asked to enable notifications", async ({ page }) => {
await page.goto("/")
await page.evaluate(async () => await navigator.serviceWorker.ready)
const notificationText = await page.getByText(/Enable notifications/)
await expect(notificationText).not.toBeAttached()
})
test("users see tack up now", async ({ page }) => {
await page.goto("/")
const yourNotificationsHeading =
await page.getByText(/Your Notifications/)
await expect(yourNotificationsHeading).toBeVisible()
})
})
}) })
})
describe("version 16.3 and under", () => {
use({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
})
test("version 16.3 and under they are informed that their iOS version isn't supported", async ({
page,
}) => {
await page.goto("/")
const sorryText = await page.getByText(/Sorry/)
await expect(sorryText).toBeVisible()
})
})
}) })
function stubNotifications(
page: Page,
args: {
permission: NotificationPermission
requestPermissionResult?: NotificationPermission
}
) {
return page.addInitScript(
(args: {
permission: NotificationPermission
requestPermissionResult?: NotificationPermission
}) => {
window.Notification = window.Notification ?? {}
Object.defineProperty(window.Notification, "permission", {
value: args.permission,
writable: true,
})
Object.defineProperty(window.Notification, "requestPermission", {
value: () =>
Promise.resolve(args.requestPermissionResult ?? args.permission),
})
},
args
)
}
function stubServiceWorker(page: Page) {
return page.addInitScript(() => {
const registration = {
pushManager: {
getSubscription() {
return Promise.resolve({ hi: "subscription" })
},
subscribe(args: Parameters<PushManager["subscribe"]>[0]) {
return Promise.resolve({ hi: "subscription" })
},
},
}
Object.defineProperty(navigator, "serviceWorker", {
value: {
register() {
return Promise.resolve(registration)
},
getRegistration() {
return Promise.resolve(registration)
},
addEventListener() {},
removeEventListener() {},
},
writable: false,
})
})
}

20
package-lock.json generated
View File

@ -16,6 +16,7 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"ua-parser-js": "^1.0.39" "ua-parser-js": "^1.0.39"
@ -14603,6 +14604,25 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remix-pwa-monorepo": {
"resolved": "git+ssh://git@github.com/remix-pwa/monorepo.git#dda9d68b1c69642679d6ff17658f21fe24c668d6",
"license": "MIT",
"workspaces": [
"packages/cli",
"packages/client",
"packages/dev",
"packages/eslint-config",
"packages/lint-staged-config",
"packages/push",
"packages/sw",
"packages/sync",
"packages/worker-runtime",
"playground"
],
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/remix-utils": { "node_modules/remix-utils": {
"version": "7.6.0", "version": "7.6.0",
"resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz", "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz",

View File

@ -23,6 +23,7 @@
"isbot": "^4.1.0", "isbot": "^4.1.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"ua-parser-js": "^1.0.39" "ua-parser-js": "^1.0.39"
@ -74,4 +75,4 @@
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
} }

View File

@ -11,10 +11,6 @@ app.get("/api", (req, res) => res.send("HI"))
// and your app is "just a request handler" // and your app is "just a request handler"
app.all("*", createRequestHandler({ build })) app.all("*", createRequestHandler({ build }))
import { register } from "register-service-worker";
register(`/service-worker.js`)
app.listen(3000, () => { app.listen(3000, () => {
console.log("App listening on http://localhost:3000") console.log("App listening on http://localhost:3000")
}) })

View File

@ -12,13 +12,13 @@ import baseConfig from "./thebabel.config.cjs"
let metaPlugin = ({ types: t }) => ({ let metaPlugin = ({ types: t }) => ({
visitor: { visitor: {
MetaProperty: (path) => { MetaProperty: (path) => {
path.replaceWith(t.identifier("undefined")); path.replaceWith(t.identifier("undefined"))
}, },
}, },
}); })
export default babelJest.createTransformer({ export default babelJest.createTransformer({
babelrc: false, babelrc: false,
...baseConfig, ...baseConfig,
plugins: [...baseConfig.plugins, metaPlugin], plugins: [...baseConfig.plugins, metaPlugin],
}); })

View File

@ -2,14 +2,10 @@ import { vitePlugin as remix } from "@remix-run/dev"
import { installGlobals } from "@remix-run/node" import { installGlobals } from "@remix-run/node"
import { defineConfig } from "vite" import { defineConfig } from "vite"
import tsconfigPaths from "vite-tsconfig-paths" import tsconfigPaths from "vite-tsconfig-paths"
import { remixPWA } from '@remix-pwa/dev' import { remixPWA } from "@remix-pwa/dev"
installGlobals() installGlobals()
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [remix(), tsconfigPaths(), remixPWA()],
remix(),
tsconfigPaths(),
remixPWA()
],
}) })