Blog · Guías

Notificaciones push en Vue: suscripción, permisos y service worker

Service worker en public, un composable que distingue permiso y suscripción y un botón que suscribe desde un clic: web push en una app Vue 3 con Vite, sin plugins.

Read in English

Para tener notificaciones push en una app Vue 3 con Vite necesitas tres piezas: un service worker en public/, un composable que cargue el SDK, exponga el estado de permiso y suscripción y suscriba desde un clic, y un backend que envíe con la clave secreta. Vite deja intactos los imports desde URL, así que no hace falta ningún plugin.

En resumen:

  • public/castly-sw.js lleva una sola línea y Vite lo copia a la raíz de dist/.
  • Vite no empaqueta import('https://castly.sh/sdk/v1/castly.mjs'), ni en desarrollo ni en el build; para TypeScript basta una declaración de módulo.
  • Un composable usePush con estado compartido distingue cinco casos: sin soporte, falta instalar, bloqueado, desactivado y activado.
  • Permiso concedido no significa suscrito: el estado real se lee con pushManager.getSubscription().
  • La clave pública va en una variable VITE_; la secreta solo vive en tu backend.

Paso 1: el service worker en public

Vite sirve la carpeta public/ en la raíz durante el desarrollo y la copia tal cual a dist/ en el build. Crea public/castly-sw.js:

importScripts('https://castly.sh/sdk/v1/sw.js');

Ese archivo recibe los mensajes push, muestra la notificación y abre la URL al hacer clic. Si quieres entender qué ocurre dentro, lo desmontamos en tu primer service worker para push.

Dos casos especiales. Si despliegas la app en una subruta con la opción base de Vite (por ejemplo /app/), el archivo se servirá en /app/castly-sw.js: pásalo en swPath al inicializar y el ámbito del service worker será esa carpeta. Y si ya usas vite-plugin-pwa en modo generateSW, no añadas un segundo service worker: incluye la URL del SDK en la opción workbox.importScripts y pasa swPath: '/sw.js'.

Paso 2: cargar el SDK desde Vite

El SDK no está en npm: es un módulo ES que se descarga de su URL. Vite trata las URL https:// como externas, así que en el build verás el import() intacto y el navegador lo resolverá en tiempo de ejecución.

Usa un import dinámico en lugar de uno estático. Con import { Castly } from 'https://…' en lo alto de un módulo, si esa URL no carga (un bloqueador, una red corporativa), falla todo el grafo de módulos que depende de él y tu app puede quedarse en blanco. Con import() el fallo queda aislado en el botón de avisos.

Para TypeScript, declara el módulo con los métodos que uses en src/castly.d.ts, y añade la clave pública a .env como VITE_CASTLY_PUBLIC_KEY=pk_live_…:

declare module 'https://castly.sh/sdk/v1/castly.mjs' {
  export interface CastlyClient {
    init(options: { publicKey: string; autoPrompt?: boolean; swPath?: string }): unknown
    isSupported(): boolean
    needsInstall(): boolean
    permission(): NotificationPermission | 'unsupported'
    subscribe(options?: { userId?: string; tags?: Record<string, string> }): Promise<{ id: string }>
    unsubscribe(): Promise<void>
  }
  export const Castly: CastlyClient
}

Recuerda que todo lo que empieza por VITE_ acaba en el JavaScript público. Es correcto para la clave pk_live_…, que solo registra suscriptores, y un error grave para la secreta.

Si tu app envía una cabecera Content-Security-Policy, añade https://castly.sh a script-src y connect-src; si no, el import falla y el composable marca el navegador como no compatible, o la suscripción falla con un error de red.

Paso 3: el composable usePush

El estado vive a nivel de módulo, fuera de la función, para que todos los componentes que usen el composable (el botón del menú y el de la página de ajustes, por ejemplo) vean lo mismo y el SDK se cargue una sola vez:

// src/composables/usePush.ts
import { readonly, ref } from 'vue'
import type { CastlyClient } from 'https://castly.sh/sdk/v1/castly.mjs'

type PushState = 'loading' | 'unsupported' | 'needs-install' | 'blocked' | 'off' | 'on'

const state = ref<PushState>('loading')
const error = ref<string | null>(null)
let castly: CastlyClient | undefined
let loading: Promise<void> | undefined

async function refresh() {
  if (!castly) return
  if (!castly.isSupported()) state.value = 'unsupported'
  else if (castly.needsInstall()) state.value = 'needs-install'
  else if (castly.permission() === 'denied') state.value = 'blocked'
  else {
    const reg = await navigator.serviceWorker.getRegistration(import.meta.env.BASE_URL)
    state.value = (await reg?.pushManager.getSubscription()) ? 'on' : 'off'
  }
}

function load() {
  loading ??= import('https://castly.sh/sdk/v1/castly.mjs')
    .then(({ Castly }) => {
      Castly.init({ publicKey: import.meta.env.VITE_CASTLY_PUBLIC_KEY, autoPrompt: false })
      castly = Castly
      return refresh()
    })
    .catch(() => {
      state.value = 'unsupported'
    })
  return loading
}

export function usePush() {
  load()

  async function enable(userId?: string) {
    if (!castly) return
    error.value = null
    try {
      await castly.subscribe({ userId }) // lo primero del clic: el gesto sigue vigente
    } catch (e) {
      error.value = e instanceof Error ? e.message : String(e)
    }
    await refresh()
  }

  async function disable() {
    await castly?.unsubscribe()
    await refresh()
  }

  return { state: readonly(state), error: readonly(error), enable, disable }
}

Tres decisiones del código merecen explicación:

  • El orden de las comprobaciones. En iPhone sin instalar, isSupported() devuelve true (funcionará cuando se instale) y needsInstall() también; por eso la segunda va después y produce un mensaje distinto.
  • Permiso frente a suscripción. Alguien puede haber concedido el permiso y haberse dado de baja después, o haber borrado los datos del sitio. permission() no basta: el estado on sale de pushManager.getSubscription(), la API estándar que dice si este navegador tiene una suscripción activa.
  • Sin esperas antes de suscribir. enable no hace ningún await antes de subscribe. Safari y Firefox solo muestran el diálogo de permiso si la petición sigue ligada al clic.

El composable devuelve state y error como readonly: los componentes pueden leerlos, pero solo enable y disable los cambian, así que la interfaz nunca se separa de lo que dice el navegador. Fíjate también en lo que hace disable: elimina la suscripción de este navegador, pero el permiso sigue concedido. El botón vuelve a off y, al activarlo de nuevo, se suscribe al momento sin mostrar el diálogo.

Paso 4: el componente

Con el composable, el componente se limita a pintar cada estado:

<!-- src/components/PushToggle.vue -->
<script setup lang="ts">
import { usePush } from '@/composables/usePush'

const props = defineProps<{ userId: string }>()
const { state, error, enable, disable } = usePush()
</script>

<template>
  <div>
    <button v-if="state === 'off'" @click="enable(props.userId)">Activar avisos</button>
    <button v-else-if="state === 'on'" @click="disable">Desactivar avisos</button>
    <p v-else-if="state === 'needs-install'">
      En iPhone, añade esta web a la pantalla de inicio y ábrela desde allí.
    </p>
    <p v-else-if="state === 'blocked'">Bloqueaste los avisos: reactívalos en los ajustes del sitio.</p>
    <p v-if="error" role="alert">{{ error }}</p>
  </div>
</template>

Pasar userId vincula el dispositivo con tu usuario: después podrás enviarle avisos por su id, en todos sus dispositivos, sin guardar endpoints. Si además pasas etiquetas, con subscribe({ userId, tags: { plan: 'pro' } }) dentro de enable, tu backend podrá enviar a un segmento con { "type": "segment", "tags": { "plan": "pro" } }. Y si tu app ya usa Pinia, la misma lógica cabe en un store; el estado a nivel de módulo basta cuando solo lo consumen un par de componentes. Para el caso de iPhone, el mensaje es solo el principio; en web push en iPhone verás cómo guiar la instalación paso a paso.

Dónde colocar el componente importa tanto como el código: junto a la acción que da sentido al aviso («Avísame cuando responda») convierte mejor que un diálogo al entrar. Hay más criterios en cuándo pedir permiso de notificaciones. Si prefieres el aviso de suscripción configurable desde el panel de castly, quita autoPrompt: false y el SDK lo mostrará según las reglas que definas.

Paso 5: enviar desde tu backend

Vue vive en el navegador, así que el envío sale de tu servidor, sea Express, Nitro, Laravel o una función serverless. En Node 18 o superior basta un fetch:

// En tu backend, nunca en el código de Vue
await fetch('https://castly.sh/v1/notifications', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `comment:${comment.id}`,
  },
  body: JSON.stringify({
    to: { type: 'users', userIds: [post.authorId] },
    title: 'Nuevo comentario en tu publicación',
    body: comment.text.slice(0, 120),
    url: `https://tuapp.com/posts/${post.id}`,
  }),
})

Un detalle propio de las SPA: si usas Vue Router con createWebHistory, al pulsar la notificación el navegador abre /posts/42 directamente. Vite resuelve esa ruta en desarrollo, pero en producción tu hosting tiene que devolver index.html para cualquier ruta de la app; si no, el clic termina en un 404. Los campos del envío, los tipos de destino y el TTL están en la referencia de la API.

Preguntas frecuentes

¿Necesito un plugin de Vite para usar el SDK?

No. Vite deja como externos los imports a URL https://, tanto en desarrollo como en el build, y el navegador descarga el módulo directamente. Solo necesitas una declaración de tipos si usas TypeScript.

¿Por qué no aparece el diálogo de permiso al pulsar el botón?

Lo más habitual es que haya un await antes de llamar a subscribe, o que el SDK aún no haya cargado. También puede ocurrir que el usuario ya bloqueara el sitio: en ese caso el navegador no vuelve a preguntar y permission() devuelve denied.

¿Funciona igual con Nuxt?

La idea es la misma, pero el composable solo debe ejecutarse en el cliente, porque en el servidor no existen navigator ni los service workers. En Nuxt, carga el SDK dentro de onMounted o en un plugin solo de cliente.

¿Puedo usar mi propio service worker en lugar de castly-sw.js?

Sí. Añade importScripts('https://castly.sh/sdk/v1/sw.js') al principio de tu service worker y pasa su ruta en swPath al inicializar el SDK.

¿Cómo pruebo las notificaciones en local?

http://localhost cuenta como contexto seguro, así que npm run dev basta para suscribirte y recibir avisos. Para probar en un teléfono necesitas HTTPS, por ejemplo con un túnel o un despliegue de prueba.

Ponlo en práctica con castlyNotificaciones push web y PWA para developers: un SDK de menos de 3 KB, una API REST y registros de entrega de cada dispositivo. Gratis hasta 10.000 notificaciones al mes.
Crear una cuenta gratis →

Envía tu primera notificación push hoy.

Crea un proyecto, añade el SDK y envía con una llamada a la API. Gratis para empezar.

Crear una cuenta gratis →